diff --git a/.github/actions/e2e/env-setup/action.yml b/.github/actions/e2e/env-setup/action.yml index dabaa752c3e..863ad27e75b 100644 --- a/.github/actions/e2e/env-setup/action.yml +++ b/.github/actions/e2e/env-setup/action.yml @@ -10,12 +10,8 @@ runs: run: echo -e "machine github.com\n login $E2E_GH_TOKEN" > ~/.netrc # PHP setup - - name: PHP Setup - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - tools: composer - coverage: none + - name: "Set up PHP" + uses: ./.github/actions/setup-php # Composer setup - name: Setup Composer diff --git a/.github/actions/setup-php/action.yml b/.github/actions/setup-php/action.yml new file mode 100644 index 00000000000..44e797aeb6d --- /dev/null +++ b/.github/actions/setup-php/action.yml @@ -0,0 +1,19 @@ +name: "Set up PHP" +description: "Extracts the required PHP version from plugin file and uses it to build PHP." + +runs: + using: composite + steps: + - name: "Get minimum PHP version" + shell: bash + id: get_min_php_version + run: | + MIN_PHP_VERSION=$(sed -n 's/.*PHP: //p' woocommerce-payments.php) + echo "MIN_PHP_VERSION=$MIN_PHP_VERSION" >> $GITHUB_OUTPUT + + - name: "Setup PHP" + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ steps.get_min_php_version.outputs.MIN_PHP_VERSION }} + tools: composer + coverage: none diff --git a/.github/actions/setup-repo/action.yml b/.github/actions/setup-repo/action.yml index 28741b60920..890fe95963f 100644 --- a/.github/actions/setup-repo/action.yml +++ b/.github/actions/setup-repo/action.yml @@ -1,29 +1,20 @@ name: "Setup WooCommerce Payments repository" description: "Handles the installation, building, and caching of the projects within the repository." -inputs: - php-version: - description: "The version of PHP that the action should set up." - default: "7.4" - runs: using: composite steps: - name: "Setup Node" uses: actions/setup-node@v3 with: - node-version-file: '.nvmrc' - cache: 'npm' + node-version-file: ".nvmrc" + cache: "npm" - name: "Enable composer dependencies caching" uses: actions/cache@v3 with: path: ~/.cache/composer/ key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} - - - name: "Setup PHP" - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ inputs.php-version }} - tools: composer - coverage: none + + - name: "Set up PHP" + uses: ./.github/actions/setup-php diff --git a/.github/workflows/build-zip-and-run-smoke-tests.yml b/.github/workflows/build-zip-and-run-smoke-tests.yml index 94599b3b88e..7afaf05a833 100644 --- a/.github/workflows/build-zip-and-run-smoke-tests.yml +++ b/.github/workflows/build-zip-and-run-smoke-tests.yml @@ -24,7 +24,7 @@ on: jobs: build-zip: name: "Build the zip file" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: "Checkout repository" uses: actions/checkout@v3 diff --git a/.github/workflows/check-changelog.yml b/.github/workflows/check-changelog.yml index 55f7391fb90..c44b2b0191e 100644 --- a/.github/workflows/check-changelog.yml +++ b/.github/workflows/check-changelog.yml @@ -11,7 +11,7 @@ concurrency: jobs: check-changelog: name: Check changelog - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: # clone the repository - uses: actions/checkout@v3 @@ -22,11 +22,8 @@ jobs: path: ~/.cache/composer/ key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} # setup PHP, but without debug extensions for reasonable performance - - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - tools: composer - coverage: none + - name: "Set up PHP" + uses: ./.github/actions/setup-php # Install composer packages. - run: composer self-update && composer install --no-progress # Fetch the target branch before running the check. diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 4dd1d7c6512..12439ae65b1 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -15,7 +15,7 @@ concurrency: jobs: generate-wc-compat-matrix: name: "Generate the matrix for woocommerce compatibility dynamically" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate_matrix.outputs.matrix }} steps: @@ -29,7 +29,7 @@ jobs: woocommerce-compatibility: name: "WC compatibility" needs: generate-wc-compat-matrix - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: WP_VERSION: ${{ matrix.wordpress }} WC_VERSION: ${{ matrix.woocommerce }} @@ -57,7 +57,7 @@ jobs: generate-wc-compat-beta-matrix: name: "Generate the matrix for compatibility-woocommerce-beta dynamically" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate_matrix.outputs.matrix }} steps: @@ -71,7 +71,7 @@ jobs: compatibility-woocommerce-beta: name: Environment - WC beta needs: generate-wc-compat-beta-matrix - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: ${{ fromJSON(needs.generate-wc-compat-beta-matrix.outputs.matrix) }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ddc2db674bc..b1401136de9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -10,7 +10,7 @@ concurrency: jobs: woocommerce-coverage: name: Code coverage - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 10 diff --git a/.github/workflows/create-pre-release.yml b/.github/workflows/create-pre-release.yml index 80ab331c563..65c20427376 100644 --- a/.github/workflows/create-pre-release.yml +++ b/.github/workflows/create-pre-release.yml @@ -16,7 +16,7 @@ defaults: jobs: create-release: name: "Create the pre-release" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: RELEASE_VERSION: ${{ inputs.releaseVersion }} diff --git a/.github/workflows/e2e-pull-request.yml b/.github/workflows/e2e-pull-request.yml index c8363faa490..ab0cd702a86 100644 --- a/.github/workflows/e2e-pull-request.yml +++ b/.github/workflows/e2e-pull-request.yml @@ -42,7 +42,7 @@ concurrency: jobs: wcpay-e2e-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 11cc17bafab..9c85a291ab1 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -30,7 +30,7 @@ env: jobs: generate-matrix: name: "Generate the matrix for subscriptions-tests dynamically" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate_matrix.outputs.matrix }} steps: @@ -42,7 +42,7 @@ jobs: # Run WCPay & subscriptions tests against specific WC versions wcpay-subscriptions-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest needs: generate-matrix strategy: fail-fast: false @@ -70,7 +70,7 @@ jobs: # Run tests against WC Checkout blocks & WC latest # [TODO] Unskip blocks tests after investigating constant failures. # blocks-tests: - # runs-on: ubuntu-20.04 + # runs-on: ubuntu-latest # name: WC - latest | blocks - shopper # env: @@ -93,7 +93,7 @@ jobs: # Run tests against WP Nightly & WC latest wp-nightly-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.github/workflows/i18n-weekly-release.yml b/.github/workflows/i18n-weekly-release.yml index 197213dec1c..11dd8a89705 100644 --- a/.github/workflows/i18n-weekly-release.yml +++ b/.github/workflows/i18n-weekly-release.yml @@ -6,7 +6,7 @@ on: jobs: i18n-release: name: Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: # clone the repository @@ -27,12 +27,8 @@ jobs: path: ~/.npm/ key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} # setup PHP, but without debug extensions for reasonable performance - - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - tools: composer - coverage: none - + - name: "Set up PHP" + uses: ./.github/actions/setup-php - name: Build release run: | npm ci diff --git a/.github/workflows/js-lint-test.yml b/.github/workflows/js-lint-test.yml index 4824d78ca19..fdbea1d59b0 100644 --- a/.github/workflows/js-lint-test.yml +++ b/.github/workflows/js-lint-test.yml @@ -11,7 +11,7 @@ concurrency: jobs: lint: name: JS linting - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: # clone the repository - uses: actions/checkout@v3 @@ -32,7 +32,7 @@ jobs: test: name: JS testing - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: # clone the repository - uses: actions/checkout@v3 diff --git a/.github/workflows/php-compatibility.yml b/.github/workflows/php-compatibility.yml index 8c0e7d73b37..abf8413b84c 100644 --- a/.github/workflows/php-compatibility.yml +++ b/.github/workflows/php-compatibility.yml @@ -11,12 +11,9 @@ jobs: # Check for version-specific PHP compatibility php-compatibility: name: PHP Compatibility - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - tools: composer - coverage: none + - name: "Set up PHP" + uses: ./.github/actions/setup-php - run: bash bin/phpcs-compat.sh diff --git a/.github/workflows/php-lint-test.yml b/.github/workflows/php-lint-test.yml index 0399a22735c..4077352f4ce 100644 --- a/.github/workflows/php-lint-test.yml +++ b/.github/workflows/php-lint-test.yml @@ -17,7 +17,7 @@ concurrency: jobs: lint: name: PHP linting - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: # clone the repository - uses: actions/checkout@v3 @@ -27,17 +27,14 @@ jobs: path: ~/.cache/composer/ key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }} # setup PHP, but without debug extensions for reasonable performance - - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - tools: composer - coverage: none + - name: "Set up PHP" + uses: ./.github/actions/setup-php # install dependencies and run linter - run: composer self-update && composer install --no-progress && ./vendor/bin/phpcs --standard=phpcs.xml.dist $(git ls-files | grep .php$) && ./vendor/bin/psalm generate-test-matrix: name: "Generate the matrix for php tests dynamically" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest outputs: matrix: ${{ steps.generate_matrix.outputs.matrix }} steps: @@ -50,7 +47,7 @@ jobs: test: name: PHP testing needs: generate-test-matrix - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 10 diff --git a/.github/workflows/post-release-updates.yml b/.github/workflows/post-release-updates.yml index f19be159407..141c53e0b16 100644 --- a/.github/workflows/post-release-updates.yml +++ b/.github/workflows/post-release-updates.yml @@ -11,7 +11,7 @@ defaults: jobs: get-last-released-version: name: "Get the last released version" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest outputs: releaseVersion: ${{ steps.current-version.outputs.RELEASE_VERSION }} @@ -31,7 +31,7 @@ jobs: create-gh-release: name: "Create a GH release" needs: get-last-released-version - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: RELEASE_VERSION: ${{ needs.get-last-released-version.outputs.releaseVersion }} @@ -75,7 +75,7 @@ jobs: merge-trunk-into-develop: name: "Merge trunk back into develop" needs: get-last-released-version - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: RELEASE_VERSION: ${{ needs.get-last-released-version.outputs.releaseVersion }} @@ -98,7 +98,7 @@ jobs: trigger-translations: name: "Trigger translations update for the release" needs: [ get-last-released-version, create-gh-release ] - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: "Checkout repository (trunk)" uses: actions/checkout@v3 @@ -114,7 +114,7 @@ jobs: update-wiki: name: "Update the wiki for the next release" needs: get-last-released-version - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: RELEASE_VERSION: ${{ needs.get-last-released-version.outputs.releaseVersion }} diff --git a/.github/workflows/pr-build-live-branch.yml b/.github/workflows/pr-build-live-branch.yml index 3ff165f2898..1fa9742f0ea 100644 --- a/.github/workflows/pr-build-live-branch.yml +++ b/.github/workflows/pr-build-live-branch.yml @@ -10,7 +10,7 @@ concurrency: jobs: build-and-inform-zip-file: name: "Build and inform the zip file" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: "Checkout repository" uses: actions/checkout@v3 diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index c99db69b350..4c9c7a7a8b1 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -29,7 +29,7 @@ defaults: jobs: process-changelog: name: "Process the changelog" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: CHANGELOG_ACTION: ${{ inputs.action-type }} RELEASE_VERSION: ${{ inputs.release-version }} diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index 84760b24ebc..7b81c24d138 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -19,7 +19,7 @@ defaults: jobs: check-code-freeze: name: "Check that today is the day of the code freeze" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest outputs: freeze: ${{ steps.check-freeze.outputs.FREEZE }} nextReleaseVersion: ${{ steps.next-version.outputs.NEXT_RELEASE_VERSION }} @@ -81,7 +81,7 @@ jobs: name: "Send notification to Slack" needs: [check-code-freeze, create-release-pr] if: ${{ ! ( inputs.skipSlackPing && needs.create-release-pr.outputs.release-pr-id ) }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest env: RELEASE_VERSION: ${{ needs.check-code-freeze.outputs.nextReleaseVersion }} RELEASE_DATE: ${{ needs.check-code-freeze.outputs.nextReleaseDate }} diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index f14a49df77b..0433e03eb51 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -53,7 +53,7 @@ defaults: jobs: prepare-release: name: "Prepare a stable release" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest outputs: branch: ${{ steps.create_branch.outputs.branch-name }} release-pr-id: ${{ steps.create-pr-to-trunk.outputs.RELEASE_PR_ID }} diff --git a/README.md b/README.md index 54b8403c470..416c6b6a728 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ We currently support the following variables: ## Test account setup -For setting up a test account follow [these instructions](https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/dev-mode/). +For setting up a test account follow [these instructions](https://woocommerce.com/document/woopayments/testing-and-troubleshooting/dev-mode/). You will need a externally accessible URL to set up the plugin. You can use ngrok for this. diff --git a/bin/cli.sh b/bin/cli.sh new file mode 100755 index 00000000000..86d167477da --- /dev/null +++ b/bin/cli.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +first_arg=${1} +if [ "${first_arg}" = "--as-root" ]; then + user=0 + command=${@:2} +else + user=www-data + command=${@:1} +fi + +command=${command:-bash} + +docker-compose exec -u ${user} wordpress ${command} diff --git a/changelog.txt b/changelog.txt index 13f4ed780c8..e440ee01f4f 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,71 @@ *** WooPayments Changelog *** += 6.5.0 - 2023-09-21 = +* Add - Add a new task prompt to set up APMs after onboarding. Fixed an issue where a notice would show up in some unintended circumstances on the APM setup. +* Add - Add an option on the Settings screen to enable merchants to migrate their Stripe Billing subscriptions to on-site billing. +* Add - Added additional meta data to payment requests +* Add - Add onboarding task incentive badge. +* Add - Add payment request class for loading, sanitizing, and escaping data (reengineering payment process) +* Add - Add the express button on the pay for order page +* Add - add WooPay checkout appearance documentation link +* Add - Fall back to site logo when a custom WooPay logo has not been defined +* Add - Introduce a new setting that enables store to opt into Subscription off-site billing via Stripe Billing. +* Add - Load payment methods through the request class (re-engineering payment process). +* Add - Record the source (Woo Subscriptions or WCPay Subscriptions) when a Stripe Billing subscription is created. +* Add - Record the subscriptions environment context in transaction meta when Stripe Billing payments are handled. +* Add - Redirect back to the pay-for-order page when it is pay-for-order order +* Add - Support kanji and kana statement descriptors for Japanese merchants +* Add - Warn about dev mode enabled on new onboarding flow choice +* Fix - Allow request classes to be extended more than once. +* Fix - Avoid empty fields in new onboarding flow +* Fix - Corrected an issue causing incorrect responses at the cancel authorization API endpoint. +* Fix - Disable automatic currency switching and switcher widgets on pay_for_order page. +* Fix - Ensure the shipping phone number field is copied to subscriptions and their orders when copying address meta. +* Fix - Ensure the Stripe Billing subscription is cancelled when the subscription is changed from WooPayments to another payment method. +* Fix - express checkout links UI consistency & area increase +* Fix - fix: save platform checkout info on blocks +* Fix - fix checkout appearance width +* Fix - Fix Currency Switcher Block flag rendering on Windows platform. +* Fix - Fix deprecation warnings on blocks checkout. +* Fix - Fix double indicators showing under Payments tab +* Fix - Fixes the currency formatting for AED and SAR currencies. +* Fix - Fix init WooPay and empty cart error +* Fix - Fix Multi-currency exchange rate date format when using custom date or time settings. +* Fix - Fix Multicurrency widget error on post/page edit screen +* Fix - Fix single currency manual rate save producing error when no changes are made +* Fix - Fix the way request params are loaded between parent and child classes. +* Fix - Fix WooPay Session Handler in Store API requests. +* Fix - Improve escaping around attributes. +* Fix - Increase admin enqueue scripts priority to avoid compatibility issues with WooCommerce Beta Tester plugin. +* Fix - Modify title in task to continue with onboarding +* Fix - Prevent WooPay-related implementation to modify non-WooPay-specific webhooks by changing their data. +* Fix - Refactor Woo Subscriptions compatibility to fix currency being able to be updated during renewals, resubscribes, or switches. +* Fix - Update inbox note logic to prevent prompt to set up payment methods from showing on not fully onboarded account. +* Update - Add notice for legacy UPE users about deferred UPE upcoming, and adjust wording for non-UPE users +* Update - Disable refund button on order edit page when there is active or lost dispute. +* Update - Enhanced Analytics SQL, added unit test for has_multi_currency_orders(). Improved code quality and test coverage. +* Update - Improved `get_all_customer_currencies` method to retrieve existing order currencies faster. +* Update - Improve the transaction details redirect user-experience by using client-side routing. +* Update - Temporarily disable saving SEPA +* Update - Update Multi-currency documentation links. +* Update - Update outdated public documentation links on WooCommerce.com +* Update - Update Tooltip component on ConvertedAmount. +* Update - When HPOS is disabled, fetch subscriptions by customer_id using the user's subscription cache to improve performance. +* Dev - Adding factor flags to control when to enter the new payment process. +* Dev - Adding issuer evidence to dispute details. Hidden behind a feature flag +* Dev - Comment: Update GH workflows to use PHP version from plugin file. +* Dev - Comment: Update occurence of all ubuntu versions to ubuntu-latest +* Dev - Deprecated the 'woocommerce_subscriptions_not_found_label' filter. +* Dev - Fix payment context and subscription payment metadata stored on subscription recurring transactions. +* Dev - Fix Tracks conditions +* Dev - Migrate DetailsLink component to TypeScript to improve code quality +* Dev - Migrate link-item.js to typescript +* Dev - Migrate woopay-item to typescript +* Dev - Remove reference to old experiment. +* Dev - Update Base_Constant to return the singleton object for same static calls. +* Dev - Updated subscriptions-core to 6.2.0 +* Dev - Update the name of the A/B experiment on new onboarding. + = 6.4.2 - 2023-09-14 = * Fix - Fix an error in the checkout when Afterpay is selected as payment method. diff --git a/client/additional-methods-setup/upe-preview-methods-selector/add-payment-methods-task.js b/client/additional-methods-setup/upe-preview-methods-selector/add-payment-methods-task.js index 38a94dcc476..b619ae96044 100644 --- a/client/additional-methods-setup/upe-preview-methods-selector/add-payment-methods-task.js +++ b/client/additional-methods-setup/upe-preview-methods-selector/add-payment-methods-task.js @@ -258,35 +258,37 @@ const AddPaymentMethodsTask = () => { components: { learnMoreLink: ( // eslint-disable-next-line max-len - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/payment-methods/additional-payment-methods/" /> + <ExternalLink href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/" /> ), }, } ) } </p> - <Notice - status="warning" - isDismissible={ false } - className="po__notice" - > - <span> - { __( - 'Some payment methods cannot be enabled because more information is needed about your account. ', - 'woocommerce-payments' - ) } - </span> - <a - // eslint-disable-next-line max-len - href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/#method-cant-be-enabled" - target="_blank" - rel="external noreferrer noopener" + { isPoInProgress && ( + <Notice + status="warning" + isDismissible={ false } + className="po__notice" > - { __( - 'Learn more about enabling additional payment methods.', - 'woocommerce-payments' - ) } - </a> - </Notice> + <span> + { __( + 'Some payment methods cannot be enabled because more information is needed about your account. ', + 'woocommerce-payments' + ) } + </span> + <a + // eslint-disable-next-line max-len + href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/#method-cant-be-enabled" + target="_blank" + rel="external noreferrer noopener" + > + { __( + 'Learn more about enabling additional payment methods.', + 'woocommerce-payments' + ) } + </a> + </Notice> + ) } <Card className="add-payment-methods-task__payment-selector-wrapper" diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index e7fcee97a18..d53f7170b68 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -690,10 +690,14 @@ export default class WCPayAPI { initWooPay( userEmail, woopayUserSession ) { const wcAjaxUrl = getConfig( 'wcAjaxUrl' ); const nonce = getConfig( 'initWooPayNonce' ); + return this.request( buildAjaxURL( wcAjaxUrl, 'init_woopay' ), { _wpnonce: nonce, email: userEmail, user_session: woopayUserSession, + order_id: getConfig( 'order_id' ), + key: getConfig( 'key' ), + billing_email: getConfig( 'billing_email' ), } ); } diff --git a/client/checkout/api/test/index.test.js b/client/checkout/api/test/index.test.js index 6c1992f6816..5a2711a4da0 100644 --- a/client/checkout/api/test/index.test.js +++ b/client/checkout/api/test/index.test.js @@ -17,7 +17,15 @@ jest.mock( 'wcpay/utils/checkout', () => ( { describe( 'WCPayAPI', () => { test( 'initializes woopay using config params', () => { buildAjaxURL.mockReturnValue( 'https://example.org/' ); - getConfig.mockReturnValue( 'foo' ); + getConfig.mockImplementation( ( key ) => { + const mockProperties = { + initWooPayNonce: 'foo', + order_id: 1, + key: 'testkey', + billing_email: 'test@example.com', + }; + return mockProperties[ key ]; + } ); const api = new WCPayAPI( {}, request ); api.initWooPay( 'foo@bar.com', 'qwerty123' ); @@ -26,6 +34,9 @@ describe( 'WCPayAPI', () => { _wpnonce: 'foo', email: 'foo@bar.com', user_session: 'qwerty123', + order_id: 1, + key: 'testkey', + billing_email: 'test@example.com', } ); } ); } ); diff --git a/client/checkout/blocks/fields.js b/client/checkout/blocks/fields.js index 7748c9ee1cd..5f47df5d286 100644 --- a/client/checkout/blocks/fields.js +++ b/client/checkout/blocks/fields.js @@ -23,10 +23,7 @@ const WCPayFields = ( { stripe, elements, billing: { billingData }, - eventRegistration: { - onPaymentProcessing, - onCheckoutAfterProcessingWithSuccess, - }, + eventRegistration: { onPaymentProcessing, onCheckoutSuccess }, emitResponse, shouldSavePayment, } ) => { @@ -36,7 +33,7 @@ const WCPayFields = ( { <p> <strong>Test mode:</strong> use the test VISA card 4242424242424242 with any expiry date and CVC, or any test card numbers listed{ ' ' } - <a href="https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/#test-cards"> + <a href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards"> here </a> . @@ -87,7 +84,7 @@ const WCPayFields = ( { api, stripe, elements, - onCheckoutAfterProcessingWithSuccess, + onCheckoutSuccess, emitResponse, shouldSavePayment ); diff --git a/client/checkout/blocks/hooks.js b/client/checkout/blocks/hooks.js index fd52b16c93b..34a7a6f504d 100644 --- a/client/checkout/blocks/hooks.js +++ b/client/checkout/blocks/hooks.js @@ -16,21 +16,20 @@ export const usePaymentCompleteHandler = ( api, stripe, elements, - onCheckoutAfterProcessingWithSuccess, + onCheckoutSuccess, emitResponse, shouldSavePayment ) => { // Once the server has completed payment processing, confirm the intent of necessary. useEffect( () => - onCheckoutAfterProcessingWithSuccess( - ( { processingResponse: { paymentDetails } } ) => - confirmCardPayment( - api, - paymentDetails, - emitResponse, - shouldSavePayment - ) + onCheckoutSuccess( ( { processingResponse: { paymentDetails } } ) => + confirmCardPayment( + api, + paymentDetails, + emitResponse, + shouldSavePayment + ) ), // not sure if we need to disable this, but kept it as-is to ensure nothing breaks. Please consider passing all the deps. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/client/checkout/blocks/saved-token-handler.js b/client/checkout/blocks/saved-token-handler.js index a2e2b0ea339..2ec311c8d5e 100644 --- a/client/checkout/blocks/saved-token-handler.js +++ b/client/checkout/blocks/saved-token-handler.js @@ -7,7 +7,7 @@ export const SavedTokenHandler = ( { api, stripe, elements, - eventRegistration: { onCheckoutAfterProcessingWithSuccess }, + eventRegistration: { onCheckoutSuccess }, emitResponse, } ) => { // Once the server has completed payment processing, confirm the intent of necessary. @@ -15,7 +15,7 @@ export const SavedTokenHandler = ( { api, stripe, elements, - onCheckoutAfterProcessingWithSuccess, + onCheckoutSuccess, emitResponse, false // No need to save a payment that has already been saved. ); diff --git a/client/checkout/blocks/upe-deferred-intent-creation/payment-processor.js b/client/checkout/blocks/upe-deferred-intent-creation/payment-processor.js index 1d0a8d9f564..989773e8400 100644 --- a/client/checkout/blocks/upe-deferred-intent-creation/payment-processor.js +++ b/client/checkout/blocks/upe-deferred-intent-creation/payment-processor.js @@ -59,7 +59,7 @@ const PaymentProcessor = ( { api, activePaymentMethod, testingInstructions, - eventRegistration: { onPaymentSetup, onCheckoutAfterProcessingWithSuccess }, + eventRegistration: { onPaymentSetup, onCheckoutSuccess }, emitResponse, paymentMethodId, upeMethods, @@ -228,7 +228,7 @@ const PaymentProcessor = ( { api, stripe, elements, - onCheckoutAfterProcessingWithSuccess, + onCheckoutSuccess, emitResponse, shouldSavePayment ); diff --git a/client/checkout/blocks/upe-fields.js b/client/checkout/blocks/upe-fields.js index f8f0b5680c0..9f1ea7d67ee 100644 --- a/client/checkout/blocks/upe-fields.js +++ b/client/checkout/blocks/upe-fields.js @@ -41,10 +41,7 @@ const WCPayUPEFields = ( { activePaymentMethod, billing: { billingData }, shippingData, - eventRegistration: { - onPaymentProcessing, - onCheckoutAfterProcessingWithSuccess, - }, + eventRegistration: { onPaymentProcessing, onCheckoutSuccess }, emitResponse, paymentIntentId, paymentIntentSecret, @@ -206,7 +203,7 @@ const WCPayUPEFields = ( { // Once the server has completed payment processing, confirm the intent if necessary. useEffect( () => - onCheckoutAfterProcessingWithSuccess( + onCheckoutSuccess( ( { orderId, processingResponse: { paymentDetails } } ) => { async function updateIntent() { if ( api.handleDuplicatePayments( paymentDetails ) ) { diff --git a/client/checkout/blocks/upe-split-fields.js b/client/checkout/blocks/upe-split-fields.js index 91d0500f3da..07b9f720da1 100644 --- a/client/checkout/blocks/upe-split-fields.js +++ b/client/checkout/blocks/upe-split-fields.js @@ -45,10 +45,7 @@ const WCPayUPEFields = ( { testingInstructions, billing: { billingData }, shippingData, - eventRegistration: { - onPaymentProcessing, - onCheckoutAfterProcessingWithSuccess, - }, + eventRegistration: { onPaymentProcessing, onCheckoutSuccess }, emitResponse, paymentMethodId, upeMethods, @@ -204,7 +201,7 @@ const WCPayUPEFields = ( { // Once the server has completed payment processing, confirm the intent if necessary. useEffect( () => - onCheckoutAfterProcessingWithSuccess( + onCheckoutSuccess( ( { orderId, processingResponse: { paymentDetails } } ) => { async function updateIntent() { if ( api.handleDuplicatePayments( paymentDetails ) ) { diff --git a/client/checkout/woopay/email-input-iframe.js b/client/checkout/woopay/email-input-iframe.js index 6c17b14fc3e..a30e7cc26ce 100644 --- a/client/checkout/woopay/email-input-iframe.js +++ b/client/checkout/woopay/email-input-iframe.js @@ -6,7 +6,11 @@ import { getConfig } from 'wcpay/utils/checkout'; import wcpayTracks from 'tracks'; import request from '../utils/request'; import { buildAjaxURL } from '../../payment-request/utils'; -import { getTargetElement, validateEmail } from './utils'; +import { + getTargetElement, + validateEmail, + appendRedirectionParams, +} from './utils'; export const handleWooPayEmailInput = async ( field, @@ -186,6 +190,9 @@ export const handleWooPayEmailInput = async ( buildAjaxURL( getConfig( 'wcAjaxUrl' ), 'get_woopay_session' ), { _ajax_nonce: getConfig( 'woopaySessionNonce' ), + order_id: getConfig( 'order_id' ), + key: getConfig( 'key' ), + billing_email: getConfig( 'billing_email' ), } ).then( ( response ) => { if ( response?.data?.session ) { @@ -534,7 +541,9 @@ export const handleWooPayEmailInput = async ( true ); if ( e.data.redirectUrl ) { - window.location = e.data.redirectUrl; + window.location = appendRedirectionParams( + e.data.redirectUrl + ); } break; case 'redirect_to_platform_checkout': diff --git a/client/checkout/woopay/express-button/express-checkout-iframe.js b/client/checkout/woopay/express-button/express-checkout-iframe.js index 7ac6bfcb275..b021e9eab15 100644 --- a/client/checkout/woopay/express-button/express-checkout-iframe.js +++ b/client/checkout/woopay/express-button/express-checkout-iframe.js @@ -5,7 +5,11 @@ import { __ } from '@wordpress/i18n'; import { getConfig } from 'utils/checkout'; import request from 'wcpay/checkout/utils/request'; import { buildAjaxURL } from 'wcpay/payment-request/utils'; -import { getTargetElement, validateEmail } from '../utils'; +import { + getTargetElement, + validateEmail, + appendRedirectionParams, +} from '../utils'; import wcpayTracks from 'tracks'; export const expressCheckoutIframe = async ( api, context, emailSelector ) => { @@ -92,6 +96,9 @@ export const expressCheckoutIframe = async ( api, context, emailSelector ) => { buildAjaxURL( getConfig( 'wcAjaxUrl' ), 'get_woopay_session' ), { _ajax_nonce: getConfig( 'woopaySessionNonce' ), + order_id: getConfig( 'order_id' ), + key: getConfig( 'key' ), + billing_email: getConfig( 'billing_email' ), } ).then( ( response ) => { if ( response?.data?.session ) { @@ -250,7 +257,9 @@ export const expressCheckoutIframe = async ( api, context, emailSelector ) => { true ); if ( e.data.redirectUrl ) { - window.location = e.data.redirectUrl; + window.location = appendRedirectionParams( + e.data.redirectUrl + ); } break; case 'redirect_to_platform_checkout': @@ -269,7 +278,9 @@ export const expressCheckoutIframe = async ( api, context, emailSelector ) => { return; } if ( response.result === 'success' ) { - window.location = response.url; + window.location = appendRedirectionParams( + response.url + ); } else { showErrorMessage(); closeIframe( false ); diff --git a/client/checkout/woopay/utils.js b/client/checkout/woopay/utils.js index 48b25423d0b..a7b9a3a6152 100644 --- a/client/checkout/woopay/utils.js +++ b/client/checkout/woopay/utils.js @@ -39,3 +39,22 @@ export const validateEmail = ( value ) => { /* eslint-enable */ return pattern.test( value ); }; + +export const appendRedirectionParams = ( woopayUrl ) => { + const isPayForOrder = window.wcpayConfig.pay_for_order; + const orderId = window.wcpayConfig.order_id; + const key = window.wcpayConfig.key; + const billingEmail = window.wcpayConfig.billing_email; + + if ( ! isPayForOrder || ! orderId || ! key ) { + return woopayUrl; + } + + const url = new URL( woopayUrl ); + url.searchParams.append( 'pay_for_order', isPayForOrder ); + url.searchParams.append( 'order_id', orderId ); + url.searchParams.append( 'key', key ); + url.searchParams.append( 'billing_email', billingEmail ); + + return url.href; +}; diff --git a/client/components/account-balances/strings.ts b/client/components/account-balances/strings.ts index 547f23da8c7..76e7c2f9721 100644 --- a/client/components/account-balances/strings.ts +++ b/client/components/account-balances/strings.ts @@ -26,7 +26,7 @@ export const fundLabelStrings = { export const documentationUrls = { depositSchedule: - 'https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule', + 'https://woocommerce.com/document/woopayments/deposits/deposit-schedule/', negativeBalance: - 'https://woocommerce.com/document/woocommerce-payments/fees-and-debits/account-showing-negative-balance', + 'https://woocommerce.com/document/woopayments/fees-and-debits/account-showing-negative-balance/', }; diff --git a/client/components/account-balances/test/index.test.tsx b/client/components/account-balances/test/index.test.tsx index 6018d85b1f2..586b5dc2027 100644 --- a/client/components/account-balances/test/index.test.tsx +++ b/client/components/account-balances/test/index.test.tsx @@ -339,7 +339,7 @@ describe( 'AccountBalances', () => { } ); expect( within( tooltip ).getByRole( 'link' ) ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule' + 'https://woocommerce.com/document/woopayments/deposits/deposit-schedule/' ); } ); @@ -358,7 +358,7 @@ describe( 'AccountBalances', () => { } ); expect( within( tooltip ).getByRole( 'link' ) ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woocommerce-payments/fees-and-debits/account-showing-negative-balance' + 'https://woocommerce.com/document/woopayments/fees-and-debits/account-showing-negative-balance/' ); } ); @@ -377,7 +377,7 @@ describe( 'AccountBalances', () => { } ); expect( within( tooltip ).getByRole( 'link' ) ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woocommerce-payments/fees-and-debits/account-showing-negative-balance' + 'https://woocommerce.com/document/woopayments/fees-and-debits/account-showing-negative-balance/' ); } ); @@ -399,7 +399,7 @@ describe( 'AccountBalances', () => { } ); expect( within( tooltip ).getByRole( 'link' ) ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule' + 'https://woocommerce.com/document/woopayments/deposits/deposit-schedule/' ); } ); diff --git a/client/components/banner-notice/index.tsx b/client/components/banner-notice/index.tsx index 6c687e10cff..dcba0e665fb 100644 --- a/client/components/banner-notice/index.tsx +++ b/client/components/banner-notice/index.tsx @@ -1,111 +1,197 @@ +/** + * Based on the @wordpress/components `Notice` component. + * Adjusted to meet WooCommerce Admin Design Library. + */ + /** * External dependencies */ -import * as React from 'react'; -import { Flex, FlexItem, Icon, Notice, Button } from '@wordpress/components'; +import React from 'react'; + +import { __ } from '@wordpress/i18n'; +import { useEffect, renderToString } from '@wordpress/element'; +import { speak } from '@wordpress/a11y'; import classNames from 'classnames'; +import { Icon, Button } from '@wordpress/components'; +import { check, info } from '@wordpress/icons'; +import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; +import CloseIcon from 'gridicons/dist/cross-small'; /** * Internal dependencies. */ -import './styles.scss'; +import './style.scss'; + +const statusIconMap = { + success: check, + error: NoticeOutlineIcon, + warning: NoticeOutlineIcon, + info: info, +}; + +type Status = keyof typeof statusIconMap; /** - * Props for the BannerNotice component. - * - * @typedef {Object} BannerNoticeProps - * @property {Icon.IconType<unknown>} icon The icon to display. + * Custom hook which announces the message with politeness based on status, + * if a valid message is provided. */ -interface BannerNoticeProps extends Notice.Props { - icon?: Icon.IconType< unknown >; +const useSpokenMessage = ( status?: string, message?: React.ReactNode ) => { + const spokenMessage = + typeof message === 'string' ? message : renderToString( message ); + const politeness = status === 'error' ? 'assertive' : 'polite'; + + useEffect( () => { + if ( spokenMessage ) { + speak( spokenMessage, politeness ); + } + }, [ spokenMessage, politeness ] ); +}; + +interface Props { + /** + * A CSS `class` to give to the wrapper element. + */ + className?: string; + /** + * The displayed message of a notice. Also used as the spoken message for + * assistive technology, unless `spokenMessage` is provided as an alternative message. + */ + children: React.ReactNode; + /** + * Determines the color of the notice: `warning` (yellow), + * `success` (green), `error` (red), or `'info'`. + * By default `'info'` will be blue, but if there is a parent Theme component + * with an accent color prop, the notice will take on that color instead. + * + * @default 'info' + */ + status?: Status; + /** + * Whether to display the default icon based on status or the icon to display. + * Supported values are: boolean, JSX.Element and `undefined`. + * + * @default undefined + */ + icon?: boolean | JSX.Element; + /** + * Whether the notice should be dismissible or not. + * + * @default true + */ + isDismissible?: boolean; + /** + * An array of action objects. Each member object should contain: + * + * - `label`: `string` containing the text of the button/link + * - `url`: `string` OR `onClick`: `( event: SyntheticEvent ) => void` to specify + * what the action does. + * - `className`: `string` (optional) to add custom classes to the button styles. + * - `variant`: `'primary' | 'secondary' | 'link'` (optional) You can denote a + * primary button action for a notice by passing a value of `primary`. + * + * The default appearance of an action button is inferred based on whether + * `url` or `onClick` are provided, rendering the button as a link if + * appropriate. If both props are provided, `url` takes precedence, and the + * action button will render as an anchor tag. + * + * @default [] + */ + actions?: ReadonlyArray< { + label: string; + className?: string; + variant?: Button.Props[ 'variant' ]; + url?: string; + onClick?: React.MouseEventHandler< HTMLAnchorElement >; + } >; + /** + * Function called when dismissing the notice + * + * @default undefined + */ + onRemove?: () => void; } -/** - * Renders a banner notice. - * - * @param {BannerNoticeProps} props Banner notice props. - * @param {Icon.IconType<unknown>} props.icon The icon to display. Supports all icons from @wordpress/icons. - * @param {Notice.Props} props.noticeProps The props for the Notice component. - * @param {string} props.noticeProps.status The status of the notice. - * @param {boolean} props.noticeProps.isDismissible Whether the notice is dismissible. - * @param {string} props.noticeProps.className The class name for the notice. - * @param {React.ReactNode} props.noticeProps.children The children of the notice. - * @param {Notice.Action[]} props.noticeProps.actions The actions for the notice. - * - * @return {JSX.Element} Rendered banner notice. - */ -function BannerNotice( props: BannerNoticeProps ): JSX.Element { - const { icon, ...noticeProps } = props; +const BannerNotice: React.FC< Props > = ( { + icon, + children, + actions = [], + className, + status = 'info', + isDismissible = true, + onRemove, +} ) => { + useSpokenMessage( status, children ); + + const iconToDisplay = icon === true ? statusIconMap[ status ] : icon; - // Add the default class name to the notice. - noticeProps.className = classNames( + const classes = classNames( + className, 'wcpay-banner-notice', - `wcpay-banner-${ noticeProps.status }-notice`, - noticeProps.className + 'is-' + status ); - // Convert the notice actions to buttons or link elements. - let actions = null; - if ( noticeProps.actions ) { - const actionClass = 'wcpay-banner-notice__action'; - actions = noticeProps.actions.map( ( action, index ) => { - // Actions that contain a URL will be rendered as a link. - // This matches WP Notice component behavior. - if ( 'url' in action ) { - return ( - <a - key={ index } - className={ actionClass } - href={ action.url } - > - { action.label } - </a> - ); - } - - return ( - <Button - key={ index } - className={ actionClass } - onClick={ action.onClick } - > - { action.label } - </Button> - ); - } ); - - // We'll render the actions ourselves so we need to remove them from the props sent to the notice component. - delete noticeProps.actions; - } + const handleRemove = () => onRemove?.(); return ( - <Notice { ...noticeProps }> - <Flex align="center" justify="flex-start"> - { icon && ( - <FlexItem - className={ `wcpay-banner-notice__icon wcpay-banner-${ noticeProps.status }-notice__icon` } - > - <Icon icon={ icon } size={ 24 } /> - </FlexItem> + <div className={ classes }> + { iconToDisplay && ( + <Icon + icon={ iconToDisplay } + className="wcpay-banner-notice__icon" + /> + ) } + <div className="wcpay-banner-notice__content"> + { children } + { actions.length > 0 && ( + <div className="wcpay-banner-notice__actions"> + { actions.map( + ( + { + className: buttonCustomClasses, + label, + variant, + onClick, + url, + }, + index + ) => { + let computedVariant = variant; + if ( variant !== 'primary' ) { + computedVariant = ! url + ? 'secondary' + : 'link'; + } + + return ( + <Button + key={ index } + href={ url } + variant={ computedVariant } + onClick={ url ? undefined : onClick } + className={ buttonCustomClasses } + > + { label } + </Button> + ); + } + ) } + </div> ) } - <FlexItem - className={ `wcpay-banner-notice__content wcpay-banner-${ noticeProps.status }-notice__content` } - > - { noticeProps.children } - { actions && ( - <Flex - className="wcpay-banner-notice__content__actions" - align="baseline" - justify="flex-start" - gap={ 4 } - > - { actions } - </Flex> + </div> + { isDismissible && ( + <Button + className="wcpay-banner-notice__dismiss" + icon={ CloseIcon } + label={ __( + 'Dismiss this notice', + 'woocommerce-payments' ) } - </FlexItem> - </Flex> - </Notice> + onClick={ handleRemove } + showTooltip={ false } + /> + ) } + </div> ); -} +}; export default BannerNotice; diff --git a/client/components/banner-notice/style.scss b/client/components/banner-notice/style.scss new file mode 100644 index 00000000000..4ab24170454 --- /dev/null +++ b/client/components/banner-notice/style.scss @@ -0,0 +1,65 @@ +.wcpay-banner-notice { + display: flex; + font-family: $default-font; + font-size: $default-font-size; + background-color: $white; + border-left: $gap-smallest solid $components-color-accent; + fill: $components-color-accent; + margin: $gap-large 0; + padding: $gap-small; + box-shadow: 0 2px 6px 0 rgba( 0, 0, 0, 0.05 ); + + &.is-success { + border-left-color: $alert-green; + fill: $alert-green; + } + + &.is-warning { + border-left-color: $alert-yellow; + fill: $alert-yellow; + } + + &.is-error { + border-left-color: $alert-red; + fill: $alert-red; + } + + &.is-warning &__icon, + &.is-error &__icon { + height: 21px; // Adjust gridicon height to match other icons + } + + &__icon { + flex-shrink: 0; + margin-right: $gap-small; + } + + &__content { + flex-grow: 1; + } + + &__actions { + display: grid; + grid-auto-flow: column; + grid-auto-columns: min-content; + column-gap: $gap-small; + margin-top: $gap-small; + } + + &__dismiss.components-button.has-icon { + flex-shrink: 0; + padding: 0; + min-width: 24px; + height: 24px; + + svg { + width: 20px; + } + } + + /* Margin exceptions */ + & + &, + &:first-child { + margin-top: 0; + } +} diff --git a/client/components/banner-notice/styles.scss b/client/components/banner-notice/styles.scss deleted file mode 100644 index fd39111e0c8..00000000000 --- a/client/components/banner-notice/styles.scss +++ /dev/null @@ -1,125 +0,0 @@ -$is-info: #007cba; -$is-info-hover: #006ba1; -$is-warning: #f0b849; -$is-warning-hover: #a16f00; -$is-error: #cc1818; -$is-error-hover: #b30f0f; -$is-success: #00a32a; -$is-success-hover: #00982a; - -.wcpay-banner-notice.components-notice { - padding: 11px 0 11px 17px; - border-left: none; - border-radius: 2px; - justify-content: flex-start; - - /* Shared styles for all variants */ - .wcpay-banner-notice__icon { - display: flex; - align-items: center; - align-self: flex-start; - min-height: auto; - min-width: auto; - margin-right: 5px; - svg { - width: 22px; - height: 22px; - } - } - .components-notice__content { - margin-top: 2px; - margin-bottom: 2px; - } - .wcpay-banner-notice__content__actions { - padding-top: 12px; - } - a.wcpay-banner-notice__action { - text-decoration: none; - } - &.is-dismissible { - padding-right: 12px; - } - .components-notice__dismiss { - height: 24px; - width: 24px; - svg { - width: 15px; - height: 15px; - fill: $gray-900; - } - } - - /* Specific styles for each variant */ - &.is-info { - background-color: #f0f6fc; - .wcpay-banner-notice__icon svg { - fill: $is-info; - } - button.wcpay-banner-notice__action { - box-shadow: inset 0 0 0 1px $is-info; - &:hover { - box-shadow: inset 0 0 0 1px $is-info-hover; - } - } - .wcpay-banner-notice__action { - color: $is-info; - &:hover { - color: $is-info-hover; - } - } - } - &.is-warning { - background-color: #fcf9e8; - .wcpay-banner-notice__icon svg { - fill: $is-warning; - } - button.wcpay-banner-notice__action { - box-shadow: inset 0 0 0 1px $is-warning; - &:hover { - box-shadow: inset 0 0 0 1px $is-warning-hover; - } - } - .wcpay-banner-notice__action { - color: $is-warning; - &:hover { - color: $is-warning-hover; - } - } - } - &.is-error { - background-color: #fcf0f1; - .wcpay-banner-notice__icon svg { - fill: $is-error; - } - button.wcpay-banner-notice__action { - box-shadow: inset 0 0 0 1px $is-error; - &:hover { - box-shadow: inset 0 0 0 1px $is-error-hover; - } - } - .wcpay-banner-notice__action { - color: $is-error; - &:hover { - color: $is-error-hover; - } - } - } - &.is-success { - background-color: #edfaef; - .wcpay-banner-notice__icon svg { - fill: $is-success; - } - button.wcpay-banner-notice__action { - box-shadow: inset 0 0 0 1px $is-success; - &:hover { - box-shadow: inset 0 0 0 1px $is-success-hover; - } - } - .wcpay-banner-notice__action { - color: $is-success; - &:hover { - color: $is-success-hover; - } - } - } -} diff --git a/client/components/banner-notice/tests/__snapshots__/index.test.tsx.snap b/client/components/banner-notice/tests/__snapshots__/index.test.tsx.snap index c7b2599f363..32e6eb3fdf7 100644 --- a/client/components/banner-notice/tests/__snapshots__/index.test.tsx.snap +++ b/client/components/banner-notice/tests/__snapshots__/index.test.tsx.snap @@ -1,198 +1,62 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Info BannerNotices renders with dismiss 1`] = ` +exports[`BannerNotice should match snapshot 1`] = ` <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice wcpaytest-notice components-notice is-info is-dismissible" + class="wcpay-banner-notice is-success" > - <div - class="components-notice__content" - > - <div - class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" - data-wp-c16t="true" - data-wp-component="Flex" - > - <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" - data-wp-c16t="true" - data-wp-component="FlexItem" - > - Test notice content - </div> - </div> - <div - class="components-notice__actions" - /> - </div> - <button - aria-label="Dismiss this notice" - class="components-button components-notice__dismiss has-icon" - type="button" + <span + class="wcpay-banner-notice__icon" + size="24" > - <svg - aria-hidden="true" - focusable="false" - height="24" - viewBox="0 0 24 24" - width="24" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" - /> - </svg> - </button> - </div> -</div> -`; - -exports[`Info BannerNotices renders with dismiss and icon 1`] = ` -<div> - <div - class="wcpay-banner-notice wcpay-banner-info-notice components-notice is-info is-dismissible" - > + Custom Icon + </span> <div - class="components-notice__content" + class="wcpay-banner-notice__content" > + Example <div - class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" - data-wp-c16t="true" - data-wp-component="Flex" + class="wcpay-banner-notice__actions" > - <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" - data-wp-c16t="true" - data-wp-component="FlexItem" + <a + class="components-button is-link" + href="https://example.com" > - <span - class="dashicon dashicons dashicons-info" - /> - </div> - <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" - data-wp-c16t="true" - data-wp-component="FlexItem" + More information + </a> + <button + class="components-button is-secondary" + type="button" > - Test notice content - </div> - </div> - <div - class="components-notice__actions" - /> - </div> - <button - aria-label="Dismiss this notice" - class="components-button components-notice__dismiss has-icon" - type="button" - > - <svg - aria-hidden="true" - focusable="false" - height="24" - viewBox="0 0 24 24" - width="24" - xmlns="http://www.w3.org/2000/svg" - > - <path - d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" - /> - </svg> - </button> - </div> -</div> -`; - -exports[`Info BannerNotices renders with dismiss and icon and actions 1`] = ` -<div> - <div - class="wcpay-banner-notice wcpay-banner-info-notice components-notice is-info is-dismissible" - > - <div - class="components-notice__content" - > - <div - class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" - data-wp-c16t="true" - data-wp-component="Flex" - > - <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" - data-wp-c16t="true" - data-wp-component="FlexItem" + Cancel + </button> + <button + class="components-button is-primary" + type="button" > - Test notice content - <div - class="components-flex wcpay-banner-notice__content__actions css-azptjn-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" - data-wp-c16t="true" - data-wp-component="Flex" - > - <button - class="components-button wcpay-banner-notice__action" - type="button" - > - Button - </button> - <a - class="wcpay-banner-notice__action" - href="https://wordpress.com" - > - URL - </a> - </div> - </div> + Submit + </button> </div> - <div - class="components-notice__actions" - /> </div> <button aria-label="Dismiss this notice" - class="components-button components-notice__dismiss has-icon" + class="components-button wcpay-banner-notice__dismiss has-icon" type="button" > <svg - aria-hidden="true" - focusable="false" + class="gridicon gridicons-cross-small" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" > - <path - d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" - /> + <g> + <path + d="M17.705 7.705l-1.41-1.41L12 10.59 7.705 6.295l-1.41 1.41L10.59 12l-4.295 4.295 1.41 1.41L12 13.41l4.295 4.295 1.41-1.41L13.41 12l4.295-4.295z" + /> + </g> </svg> </button> </div> </div> `; - -exports[`Info BannerNotices renders without dismiss and icon 1`] = ` -<div> - <div - class="wcpay-banner-notice wcpay-banner-info-notice components-notice is-info" - > - <div - class="components-notice__content" - > - <div - class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" - data-wp-c16t="true" - data-wp-component="Flex" - > - <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" - data-wp-c16t="true" - data-wp-component="FlexItem" - > - Test notice content - </div> - </div> - <div - class="components-notice__actions" - /> - </div> - </div> -</div> -`; diff --git a/client/components/banner-notice/tests/index.test.tsx b/client/components/banner-notice/tests/index.test.tsx index 3a98e3363b3..819a9bf935e 100644 --- a/client/components/banner-notice/tests/index.test.tsx +++ b/client/components/banner-notice/tests/index.test.tsx @@ -2,145 +2,111 @@ * External dependencies */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; +import { mocked } from 'ts-jest/utils'; +import { speak } from '@wordpress/a11y'; /** * Internal dependencies */ import BannerNotice from '../'; -describe( 'Info BannerNotices renders', () => { - test( 'with dismiss', () => { - const { container } = render( - <BannerNotice - status="info" - className="wcpaytest-notice" - children={ 'Test notice content' } - isDismissible={ true } - /> - ); - expect( container ).toMatchSnapshot(); - } ); +jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) ); - test( 'with dismiss and icon', () => { - const { container } = render( - <BannerNotice - status="info" - icon={ 'info' } - children={ 'Test notice content' } - isDismissible={ true } - /> - ); - expect( container ).toMatchSnapshot(); +describe( 'BannerNotice', () => { + beforeEach( () => { + mocked( speak ).mockClear(); } ); - test( 'with dismiss and icon and actions', () => { + it( 'should match snapshot', () => { + const onClick = jest.fn(); const { container } = render( <BannerNotice - status="info" - children={ 'Test notice content' } - isDismissible={ true } + status="success" + icon={ <span>Custom Icon</span> } actions={ [ - { - label: 'Button', - onClick: jest.fn(), - }, - { - label: 'URL', - url: 'https://wordpress.com', - }, + { label: 'More information', url: 'https://example.com' }, + { label: 'Cancel', onClick }, + { label: 'Submit', onClick, variant: 'primary' }, ] } - /> + > + Example + </BannerNotice> ); expect( container ).toMatchSnapshot(); } ); - test( 'without dismiss and icon', () => { - const { container } = render( - <BannerNotice - status="info" - children={ 'Test notice content' } - isDismissible={ false } - /> - ); - expect( container ).toMatchSnapshot(); + it( 'should default to info status', () => { + const { + container: { firstChild }, + } = render( <BannerNotice>FYI</BannerNotice> ); + + expect( firstChild ).toHaveClass( 'is-info' ); } ); -} ); -describe( 'Action click triggers callback', () => { - test( 'with dismiss and icon and actions', () => { - const onClickMock = jest.fn(); - const { getByText } = render( - <BannerNotice - status="warning" - children={ 'Test notice content' } - isDismissible={ true } - actions={ [ - { - label: 'Button', - onClick: onClickMock, - }, - { - label: 'URL', - url: 'https://wordpress.com', - }, - ] } - /> + /***************** */ + + it( 'calls action onClick when clicked', () => { + const onClick = jest.fn(); + render( + <BannerNotice actions={ [ { label: 'Action', onClick } ] }> + Notice with Action + </BannerNotice> ); - fireEvent.click( getByText( 'Button' ) ); - expect( onClickMock ).toHaveBeenCalled(); + user.click( screen.getByText( 'Action' ) ); + + expect( onClick ).toHaveBeenCalled(); } ); - test( 'With icon and multiple button actions', () => { - const onButtonClickOne = jest.fn(); - const onButtonClickTwo = jest.fn(); - const { getByText } = render( - <BannerNotice - status="warning" - children={ 'Test notice content' } - isDismissible={ true } - actions={ [ - { - label: 'Button one', - onClick: onButtonClickOne, - }, - { - label: 'Button two', - onClick: onButtonClickTwo, - }, - ] } - /> + it( 'calls onRemove when dismiss button is clicked', () => { + const onRemove = jest.fn(); + render( + <BannerNotice onRemove={ onRemove }> + Dismissible Notice + </BannerNotice> ); - expect( onButtonClickOne ).not.toHaveBeenCalled(); - expect( onButtonClickTwo ).not.toHaveBeenCalled(); + user.click( screen.getByLabelText( 'Dismiss this notice' ) ); - // Click Button 1 - fireEvent.click( getByText( 'Button one' ) ); - expect( onButtonClickOne ).toHaveBeenCalled(); - expect( onButtonClickTwo ).not.toHaveBeenCalled(); - - // Click Button 1 - fireEvent.click( getByText( 'Button two' ) ); - expect( onButtonClickTwo ).toHaveBeenCalled(); + expect( onRemove ).toHaveBeenCalled(); } ); -} ); -describe( 'Dismiss click triggers callback', () => { - test( 'with dismiss and icon and actions', () => { - const onDismissMock = jest.fn(); - const { getByLabelText } = render( - <BannerNotice - status="error" - children={ 'Test notice content' } - isDismissible={ true } - onRemove={ onDismissMock } - /> - ); + describe( 'useSpokenMessage', () => { + it( 'should speak the given message', () => { + render( <BannerNotice>FYI</BannerNotice> ); + + expect( speak ).toHaveBeenCalledWith( 'FYI', 'polite' ); + } ); + + it( 'should speak the given message by implicit politeness by status', () => { + render( <BannerNotice status="error">Uh oh!</BannerNotice> ); + + expect( speak ).toHaveBeenCalledWith( 'Uh oh!', 'assertive' ); + } ); + + it( 'should coerce a message to a string', () => { + render( + <BannerNotice> + With <em>emphasis</em> this time. + </BannerNotice> + ); + + expect( speak ).toHaveBeenCalledWith( + 'With <em>emphasis</em> this time.', + 'polite' + ); + } ); + + it( 'should not re-speak an effectively equivalent element message', () => { + const { rerender } = render( + <BannerNotice>Duplicated notice message.</BannerNotice> + ); + rerender( <BannerNotice>Duplicated notice message.</BannerNotice> ); - fireEvent.click( getByLabelText( 'Dismiss this notice' ) ); - expect( onDismissMock ).toHaveBeenCalled(); + expect( speak ).toHaveBeenCalledTimes( 1 ); + } ); } ); } ); diff --git a/client/components/currency-information-for-methods/index.js b/client/components/currency-information-for-methods/index.js index 801af83e6db..38ca0b133cf 100644 --- a/client/components/currency-information-for-methods/index.js +++ b/client/components/currency-information-for-methods/index.js @@ -11,7 +11,7 @@ import interpolateComponents from '@automattic/interpolate-components'; */ import { useCurrencies, useEnabledCurrencies } from '../../data'; import WCPaySettingsContext from '../../settings/wcpay-settings-context'; -import InlineNotice from '../inline-notice'; +import InlineNotice from 'components/inline-notice'; import PaymentMethodsMap from '../../payment-methods-map'; const ListToCommaSeparatedSentencePartConverter = ( items ) => { @@ -90,7 +90,7 @@ const CurrencyInformationForMethods = ( { selectedMethods } ) => { if ( missingCurrencyLabels.length > 0 ) { return ( - <InlineNotice status="info" isDismissible={ false }> + <InlineNotice icon status="info" isDismissible={ false }> { interpolateComponents( { mixedString: sprintf( __( diff --git a/client/components/deposits-overview/next-deposit.tsx b/client/components/deposits-overview/next-deposit.tsx index 4c17b018e93..cf9a08b23fd 100644 --- a/client/components/deposits-overview/next-deposit.tsx +++ b/client/components/deposits-overview/next-deposit.tsx @@ -10,8 +10,6 @@ import { Icon, } from '@wordpress/components'; import { calendar } from '@wordpress/icons'; -import InfoOutlineIcon from 'gridicons/dist/info-outline'; -import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; import interpolateComponents from '@automattic/interpolate-components'; import { __, sprintf } from '@wordpress/i18n'; @@ -23,7 +21,7 @@ import { getNextDeposit } from './utils'; import DepositStatusChip from 'components/deposit-status-chip'; import { getDepositDate } from 'deposits/utils'; import { useAllDepositsOverviews, useDepositIncludesLoan } from 'wcpay/data'; -import BannerNotice from 'wcpay/components/banner-notice'; +import InlineNotice from 'components/inline-notice'; import { useSelectedCurrency } from 'wcpay/overview/hooks'; import type * as AccountOverview from 'wcpay/types/account-overview'; @@ -33,11 +31,7 @@ type NextDepositProps = { }; const DepositIncludesLoanPayoutNotice = () => ( - <BannerNotice - status="warning" - icon={ <InfoOutlineIcon /> } - isDismissible={ false } - > + <InlineNotice icon status="warning" isDismissible={ false }> { interpolateComponents( { mixedString: __( 'This deposit will include funds from your WooCommerce Capital loan. {{learnMoreLink}}Learn more{{/learnMoreLink}}', @@ -49,7 +43,7 @@ const DepositIncludesLoanPayoutNotice = () => ( // eslint-disable-next-line jsx-a11y/anchor-has-content <a href={ - 'https://woocommerce.com/document/woocommerce-payments/stripe-capital/overview' + 'https://woocommerce.com/document/woopayments/stripe-capital/overview/' } target="_blank" rel="noreferrer" @@ -57,13 +51,13 @@ const DepositIncludesLoanPayoutNotice = () => ( ), }, } ) } - </BannerNotice> + </InlineNotice> ); const NewAccountWaitingPeriodNotice = () => ( - <BannerNotice + <InlineNotice status="warning" - icon={ <NoticeOutlineIcon /> } + icon className="new-account-waiting-period-notice" isDismissible={ false } > @@ -79,18 +73,18 @@ const NewAccountWaitingPeriodNotice = () => ( <a target="_blank" rel="noopener noreferrer" - href="https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule/#section-1" + href="https://woocommerce.com/document/woopayments/deposits/deposit-schedule/#new-accounts" /> ), }, } ) } - </BannerNotice> + </InlineNotice> ); const NegativeBalanceDepositsPausedNotice = () => ( - <BannerNotice + <InlineNotice status="warning" - icon={ <NoticeOutlineIcon /> } + icon className="negative-balance-deposits-paused-notice" isDismissible={ false } > @@ -110,12 +104,12 @@ const NegativeBalanceDepositsPausedNotice = () => ( <a target="_blank" rel="noopener noreferrer" - href="https://woocommerce.com/document/woocommerce-payments/fees-and-debits/account-showing-negative-balance/" + href="https://woocommerce.com/document/woopayments/fees-and-debits/account-showing-negative-balance/" /> ), }, } ) } - </BannerNotice> + </InlineNotice> ); /** diff --git a/client/components/deposits-overview/recent-deposits-list.tsx b/client/components/deposits-overview/recent-deposits-list.tsx index 53811b8fcb1..a0cbfea5fc2 100644 --- a/client/components/deposits-overview/recent-deposits-list.tsx +++ b/client/components/deposits-overview/recent-deposits-list.tsx @@ -11,7 +11,6 @@ import { } from '@wordpress/components'; import { calendar } from '@wordpress/icons'; import { Link } from '@woocommerce/components'; -import InfoOutlineIcon from 'gridicons/dist/info-outline'; import { Fragment } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -24,7 +23,7 @@ import { getDepositDate } from 'deposits/utils'; import { CachedDeposit } from 'wcpay/types/deposits'; import { formatCurrency } from 'wcpay/utils/currency'; import { getDetailsURL } from 'wcpay/components/details-link'; -import BannerNotice from '../banner-notice'; +import InlineNotice from '../inline-notice'; interface DepositRowProps { deposit: CachedDeposit; @@ -89,10 +88,10 @@ const RecentDepositsList: React.FC< RecentDepositsProps > = ( { <Fragment key={ deposit.id }> <DepositTableRow deposit={ deposit } /> { deposit.id === oldestPendingDepositId && ( - <BannerNotice + <InlineNotice className="wcpay-deposits-overview__business-day-delay-notice" status="info" - icon={ <InfoOutlineIcon /> } + icon children={ 'Deposits pending or in-transit may take 1-2 business days to appear in your bank account once dispatched' } diff --git a/client/components/deposits-overview/style.scss b/client/components/deposits-overview/style.scss index 7be1679ced9..9b2c500af31 100644 --- a/client/components/deposits-overview/style.scss +++ b/client/components/deposits-overview/style.scss @@ -19,7 +19,7 @@ } } } - .wcpay-banner-notice.components-notice { + .wcpay-inline-notice.components-notice { margin: 0; } @@ -27,7 +27,7 @@ // in the notices container and to the business delay // notice if it's the last child of the Deposit history table. &__notices__container - > .wcpay-banner-notice.components-notice:not( :last-child ), + > .wcpay-inline-notice.components-notice:not( :last-child ), .wcpay-deposits-overview__business-day-delay-notice:last-child { margin-bottom: 16px; } diff --git a/client/components/deposits-overview/suspended-deposit-notice.tsx b/client/components/deposits-overview/suspended-deposit-notice.tsx index 1de4f17af92..de5aa05fca3 100644 --- a/client/components/deposits-overview/suspended-deposit-notice.tsx +++ b/client/components/deposits-overview/suspended-deposit-notice.tsx @@ -9,8 +9,7 @@ import { Link } from '@woocommerce/components'; /** * Internal dependencies */ -import BannerNotice from 'components/banner-notice'; -import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; +import InlineNotice from 'components/inline-notice'; /** * Renders a notice informing the user that their deposits are suspended. @@ -19,9 +18,9 @@ import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; */ function SuspendedDepositNotice(): JSX.Element { return ( - <BannerNotice + <InlineNotice className="wcpay-deposits-overview__suspended-notice" - icon={ <NoticeOutlineIcon /> } + icon isDismissible={ false } status="warning" > @@ -36,13 +35,13 @@ function SuspendedDepositNotice(): JSX.Element { suspendLink: ( <Link href={ - 'https://woocommerce.com/document/woocommerce-payments/deposits/why-deposits-suspended/' + 'https://woocommerce.com/document/woopayments/deposits/why-deposits-suspended/' } /> ), }, } ) } - </BannerNotice> + </InlineNotice> ); } diff --git a/client/components/deposits-overview/test/__snapshots__/index.tsx.snap b/client/components/deposits-overview/test/__snapshots__/index.tsx.snap index f042f06fa24..46810596530 100644 --- a/client/components/deposits-overview/test/__snapshots__/index.tsx.snap +++ b/client/components/deposits-overview/test/__snapshots__/index.tsx.snap @@ -325,7 +325,7 @@ exports[`Deposits Overview information Component Renders 1`] = ` </div> </div> <div - class="wcpay-banner-notice wcpay-banner-info-notice wcpay-deposits-overview__business-day-delay-notice components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice wcpay-deposits-overview__business-day-delay-notice components-notice is-info" > <div class="components-notice__content" @@ -336,7 +336,7 @@ exports[`Deposits Overview information Component Renders 1`] = ` data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -355,7 +355,7 @@ exports[`Deposits Overview information Component Renders 1`] = ` </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -412,7 +412,7 @@ exports[`Deposits Overview information Component Renders 1`] = ` exports[`Suspended Deposit Notice Renders Component Renders 1`] = ` <div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice wcpay-deposits-overview__suspended-notice components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice wcpay-deposits-overview__suspended-notice components-notice is-warning" > <div class="components-notice__content" @@ -423,7 +423,7 @@ exports[`Suspended Deposit Notice Renders Component Renders 1`] = ` data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -442,7 +442,7 @@ exports[`Suspended Deposit Notice Renders Component Renders 1`] = ` </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -453,7 +453,7 @@ exports[`Suspended Deposit Notice Renders Component Renders 1`] = ` . <a data-link-type="wc-admin" - href="https://woocommerce.com/document/woocommerce-payments/deposits/why-deposits-suspended/" + href="https://woocommerce.com/document/woopayments/deposits/why-deposits-suspended/" > Learn more </a> diff --git a/client/components/deposits-overview/test/index.tsx b/client/components/deposits-overview/test/index.tsx index b22c1ef9e50..a69aa3408cb 100644 --- a/client/components/deposits-overview/test/index.tsx +++ b/client/components/deposits-overview/test/index.tsx @@ -364,7 +364,7 @@ describe( 'Deposits Overview information', () => { } ) ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woocommerce-payments/stripe-capital/overview' + 'https://woocommerce.com/document/woopayments/stripe-capital/overview/' ); } ); @@ -428,7 +428,7 @@ describe( 'Deposits Overview information', () => { } ); expect( getByRole( 'link', { name: /Why\?/ } ) ).toHaveAttribute( 'href', - 'https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule/#section-1' + 'https://woocommerce.com/document/woopayments/deposits/deposit-schedule/#new-accounts' ); } ); } ); diff --git a/client/components/deposits-status/index.tsx b/client/components/deposits-status/index.tsx index 0e2c1611b6f..2d40bd06b48 100644 --- a/client/components/deposits-status/index.tsx +++ b/client/components/deposits-status/index.tsx @@ -61,7 +61,7 @@ const DepositsStatus: React.FC< Props > = ( { icon = <GridiconNotice size={ iconSize } />; } else if ( showSuspendedNotice ) { const learnMoreHref = - 'https://woocommerce.com/document/woocommerce-payments/deposits/why-deposits-suspended/'; + 'https://woocommerce.com/document/woopayments/deposits/why-deposits-suspended/'; description = createInterpolateElement( /* translators: <a> - suspended accounts FAQ URL */ __( diff --git a/client/components/deposits-status/test/__snapshots__/index.js.snap b/client/components/deposits-status/test/__snapshots__/index.js.snap index 926ff394eb2..b5853f51013 100644 --- a/client/components/deposits-status/test/__snapshots__/index.js.snap +++ b/client/components/deposits-status/test/__snapshots__/index.js.snap @@ -20,7 +20,7 @@ exports[`DepositsStatus renders blocked status 1`] = ` </svg> Temporarily suspended ( <a - href="https://woocommerce.com/document/woocommerce-payments/deposits/why-deposits-suspended/" + href="https://woocommerce.com/document/woopayments/deposits/why-deposits-suspended/" rel="noopener noreferrer" target="_blank" > @@ -51,7 +51,7 @@ exports[`DepositsStatus renders blocked status 2`] = ` </svg> Temporarily suspended ( <a - href="https://woocommerce.com/document/woocommerce-payments/deposits/why-deposits-suspended/" + href="https://woocommerce.com/document/woopayments/deposits/why-deposits-suspended/" rel="noopener noreferrer" target="_blank" > diff --git a/client/components/details-link/index.js b/client/components/details-link/index.js deleted file mode 100644 index 1571f5be74c..00000000000 --- a/client/components/details-link/index.js +++ /dev/null @@ -1,24 +0,0 @@ -/** @format **/ - -/** - * External dependencies - */ -import InfoOutlineIcon from 'gridicons/dist/info-outline'; -import { Link } from '@woocommerce/components'; -import { getAdminUrl } from 'wcpay/utils'; - -export const getDetailsURL = ( id, parentSegment ) => - getAdminUrl( { - page: 'wc-admin', - path: `/payments/${ parentSegment }/details`, - id, - } ); - -const DetailsLink = ( { id, parentSegment } ) => - id ? ( - <Link href={ getDetailsURL( id, parentSegment ) }> - <InfoOutlineIcon size={ 18 } /> - </Link> - ) : null; - -export default DetailsLink; diff --git a/client/components/details-link/index.tsx b/client/components/details-link/index.tsx new file mode 100644 index 00000000000..599247ce6c5 --- /dev/null +++ b/client/components/details-link/index.tsx @@ -0,0 +1,53 @@ +/** @format **/ + +/** + * External dependencies + */ +import React from 'react'; +import InfoOutlineIcon from 'gridicons/dist/info-outline'; +import { Link } from '@woocommerce/components'; + +/** + * Internal dependencies + */ +import { getAdminUrl } from 'wcpay/utils'; + +/** + * The parent segment is the first part of the URL after the /payments/ path. + */ +type ParentSegment = 'deposits' | 'transactions' | 'disputes'; + +export const getDetailsURL = ( + /** + * The ID of the object to link to. + */ + id: string, + /** + * The parent segment is the first part of the URL after the /payments/ path. + */ + parentSegment: ParentSegment +): string => + getAdminUrl( { + page: 'wc-admin', + path: `/payments/${ parentSegment }/details`, + id, + } ); + +interface DetailsLinkProps { + /** + * The ID of the object to link to. + */ + id?: string; + /** + * The parent segment is the first part of the URL after the /payments/ path. + */ + parentSegment: ParentSegment; +} +const DetailsLink: React.FC< DetailsLinkProps > = ( { id, parentSegment } ) => + id ? ( + <Link href={ getDetailsURL( id, parentSegment ) }> + <InfoOutlineIcon size={ 18 } /> + </Link> + ) : null; + +export default DetailsLink; diff --git a/client/components/details-link/test/__snapshots__/index.js.snap b/client/components/details-link/test/__snapshots__/index.test.tsx.snap similarity index 100% rename from client/components/details-link/test/__snapshots__/index.js.snap rename to client/components/details-link/test/__snapshots__/index.test.tsx.snap diff --git a/client/components/details-link/test/index.js b/client/components/details-link/test/index.test.tsx similarity index 96% rename from client/components/details-link/test/index.js rename to client/components/details-link/test/index.test.tsx index 6c39ea4d343..172eeac2454 100644 --- a/client/components/details-link/test/index.js +++ b/client/components/details-link/test/index.test.tsx @@ -3,6 +3,7 @@ /** * External dependencies */ +import React from 'react'; import { render } from '@testing-library/react'; /** diff --git a/client/components/dispute-status-chip/index.tsx b/client/components/dispute-status-chip/index.tsx index df3a60e888c..ccb9e11a553 100644 --- a/client/components/dispute-status-chip/index.tsx +++ b/client/components/dispute-status-chip/index.tsx @@ -11,8 +11,7 @@ import React from 'react'; import Chip from '../chip'; import displayStatus from './mappings'; import { formatStringValue } from 'utils'; -import { isDueWithin } from 'wcpay/disputes/utils'; -import { disputeAwaitingResponseStatuses } from 'wcpay/disputes/filters/config'; +import { isAwaitingResponse, isDueWithin } from 'wcpay/disputes/utils'; import type { CachedDispute, DisputeStatus, @@ -27,7 +26,7 @@ const DisputeStatusChip: React.FC< Props > = ( { status, dueBy } ) => { const mapping = displayStatus[ status ] || {}; const message = mapping.message || formatStringValue( status ); - const needsResponse = disputeAwaitingResponseStatuses.includes( status ); + const needsResponse = isAwaitingResponse( status ); const isUrgent = needsResponse && dueBy && isDueWithin( { dueBy, days: 3 } ); diff --git a/client/components/error-boundary/index.js b/client/components/error-boundary/index.js index cc618fa5b91..6fba86a54d9 100644 --- a/client/components/error-boundary/index.js +++ b/client/components/error-boundary/index.js @@ -3,7 +3,7 @@ */ import { Component } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import InlineNotice from '../inline-notice'; +import InlineNotice from 'components/inline-notice'; class ErrorBoundary extends Component { constructor() { @@ -30,7 +30,7 @@ class ErrorBoundary extends Component { } return ( - <InlineNotice status="error" isDismissible={ false }> + <InlineNotice icon status="error" isDismissible={ false }> { __( 'There was an error rendering this view. Please contact support for assistance if the problem persists.', 'woocommerce-payments' diff --git a/client/components/horizontal-list/style.scss b/client/components/horizontal-list/style.scss index ba52172618f..f133cae1da1 100755 --- a/client/components/horizontal-list/style.scss +++ b/client/components/horizontal-list/style.scss @@ -45,10 +45,14 @@ @include font-size( 14 ); } .woocommerce-list__item-title { - color: $studio-gray-60; + text-transform: uppercase; + color: $gray-700; + font-size: 11px; + font-weight: 600; } .woocommerce-list__item-content { color: $studio-gray-80; + display: flex; } } .woocommerce-list__item:first-child { diff --git a/client/components/inline-notice/index.tsx b/client/components/inline-notice/index.tsx index af55ee519f8..d0c59812e96 100644 --- a/client/components/inline-notice/index.tsx +++ b/client/components/inline-notice/index.tsx @@ -1,23 +1,111 @@ /** * External dependencies */ -import React from 'react'; -import { Notice } from '@wordpress/components'; +import * as React from 'react'; +import { Flex, FlexItem, Icon, Notice, Button } from '@wordpress/components'; import classNames from 'classnames'; +import CheckmarkIcon from 'gridicons/dist/checkmark'; +import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; +import InfoOutlineIcon from 'gridicons/dist/info-outline'; /** - * Internal dependencies + * Internal dependencies. */ -import './style.scss'; - -const InlineNotice: React.FunctionComponent< Notice.Props > = ( { - className, - ...restProps -} ) => ( - <Notice - className={ classNames( 'wcpay-inline-notice', className ) } - { ...restProps } - /> -); +import './styles.scss'; + +interface InlineNoticeProps extends Notice.Props { + /** + * Whether to display the default icon based on status prop or the icon to display. + * Supported values are: boolean, JSX.Element and `undefined`. + * + * @default undefined + */ + icon?: boolean | JSX.Element; +} + +/** + * Renders a banner notice. + */ +function InlineNotice( props: InlineNoticeProps ): JSX.Element { + const { icon, actions, children, ...noticeProps } = props; + + // Add the default class name to the notice. + noticeProps.className = classNames( + 'wcpay-inline-notice', + `wcpay-inline-${ noticeProps.status }-notice`, + noticeProps.className + ); + + // Use default icon based on status if icon === true. + let iconToDisplay = icon; + if ( iconToDisplay === true ) { + switch ( noticeProps.status ) { + case 'success': + iconToDisplay = <CheckmarkIcon />; + break; + case 'error': + case 'warning': + iconToDisplay = <NoticeOutlineIcon />; + break; + case 'info': + default: + iconToDisplay = <InfoOutlineIcon />; + break; + } + } + + // Convert the notice actions to buttons or link elements. + const actionClass = 'wcpay-inline-notice__action'; + const mappedActions = actions?.map( ( action, index ) => { + // Actions that contain a URL will be rendered as a link. + // This matches WP Notice component behavior. + if ( 'url' in action ) { + return ( + <a key={ index } className={ actionClass } href={ action.url }> + { action.label } + </a> + ); + } + + return ( + <Button + key={ index } + className={ actionClass } + onClick={ action.onClick } + > + { action.label } + </Button> + ); + } ); + + return ( + <Notice { ...noticeProps }> + <Flex align="center" justify="flex-start"> + { iconToDisplay && ( + <FlexItem + className={ `wcpay-inline-notice__icon wcpay-inline-${ noticeProps.status }-notice__icon` } + > + <Icon icon={ iconToDisplay } size={ 24 } /> + </FlexItem> + ) } + <FlexItem + className={ `wcpay-inline-notice__content wcpay-inline-${ noticeProps.status }-notice__content` } + > + { children } + { mappedActions && ( + <Flex + className="wcpay-inline-notice__content__actions" + align="baseline" + justify="flex-start" + gap={ 4 } + > + { mappedActions } + </Flex> + ) } + </FlexItem> + </Flex> + </Notice> + ); +} export default InlineNotice; diff --git a/client/components/inline-notice/style.scss b/client/components/inline-notice/style.scss deleted file mode 100644 index 5f2f855d2cc..00000000000 --- a/client/components/inline-notice/style.scss +++ /dev/null @@ -1,11 +0,0 @@ -.wcpay-inline-notice { - // increasing the specificity of the styles to override the Gutenberg ones - &#{&} { - margin: 0; - margin-bottom: $grid-unit-30; - } - - &.is-info { - background: #def1f7; - } -} diff --git a/client/components/inline-notice/styles.scss b/client/components/inline-notice/styles.scss new file mode 100644 index 00000000000..6c4059d7273 --- /dev/null +++ b/client/components/inline-notice/styles.scss @@ -0,0 +1,103 @@ +.wcpay-inline-notice.components-notice { + margin: $gap-large 0; + padding: 11px 0 11px 17px; + border-left: none; + border-radius: 2px; + justify-content: flex-start; + + /* Margin exceptions */ + @at-root .components-modal__header + &, + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + /* Shared styles for all variants */ + .wcpay-inline-notice__icon { + display: flex; + align-items: center; + align-self: flex-start; + min-height: auto; + min-width: auto; + margin-right: 5px; + svg { + width: 22px; + height: 22px; + } + } + .components-notice__content { + margin-top: 2px; + margin-bottom: 2px; + } + .wcpay-inline-notice__content__actions { + padding-top: 12px; + } + a.wcpay-inline-notice__action { + text-decoration: none; + } + &.is-dismissible { + padding-right: 12px; + } + .components-notice__dismiss { + height: 24px; + width: 24px; + svg { + width: 15px; + height: 15px; + fill: $gray-900; + } + } + + /* Specific styles for each variant */ + &.is-info { + background-color: $wp-blue-0; + .wcpay-inline-notice__icon svg { + fill: $wp-blue-70; + } + button.wcpay-inline-notice__action { + box-shadow: inset 0 0 0 1px $wp-blue-70; + } + .wcpay-inline-notice__action { + color: $wp-blue-70; + } + } + &.is-warning { + background-color: #fcf9e8; + .wcpay-inline-notice__icon svg { + fill: $wp-yellow-70; + } + button.wcpay-inline-notice__action { + box-shadow: inset 0 0 0 1px $wp-yellow-70; + } + .wcpay-inline-notice__action { + color: $wp-yellow-70; + } + } + &.is-error { + background-color: $wp-red-0; + .wcpay-inline-notice__icon svg { + fill: $wp-red-70; + } + button.wcpay-inline-notice__action { + box-shadow: inset 0 0 0 1px $wp-red-70; + } + .wcpay-inline-notice__action { + color: $wp-red-70; + } + } + &.is-success { + background-color: #edfaef; + .wcpay-inline-notice__icon svg { + fill: $wp-green-70; + } + button.wcpay-inline-notice__action { + box-shadow: inset 0 0 0 1px $wp-green-70; + } + .wcpay-inline-notice__action { + color: $wp-green-70; + } + } +} diff --git a/client/components/inline-notice/tests/__snapshots__/index.test.tsx.snap b/client/components/inline-notice/tests/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..ed2038c16b5 --- /dev/null +++ b/client/components/inline-notice/tests/__snapshots__/index.test.tsx.snap @@ -0,0 +1,293 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Info InlineNotices renders with dismiss 1`] = ` +<div> + <div + class="wcpay-inline-notice wcpay-inline-info-notice wcpaytest-notice components-notice is-info is-dismissible" + > + <div + class="components-notice__content" + > + <div + class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" + data-wp-c16t="true" + data-wp-component="Flex" + > + <div + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + Test notice content + </div> + </div> + <div + class="components-notice__actions" + /> + </div> + <button + aria-label="Dismiss this notice" + class="components-button components-notice__dismiss has-icon" + type="button" + > + <svg + aria-hidden="true" + focusable="false" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" + /> + </svg> + </button> + </div> +</div> +`; + +exports[`Info InlineNotices renders with dismiss and icon 1`] = ` +<div> + <div + class="wcpay-inline-notice wcpay-inline-info-notice components-notice is-info is-dismissible" + > + <div + class="components-notice__content" + > + <div + class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" + data-wp-c16t="true" + data-wp-component="Flex" + > + <div + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + <svg + class="gridicon gridicons-info-outline" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M13 9h-2V7h2v2zm0 2h-2v6h2v-6zm-1-7c-4.411 0-8 3.589-8 8s3.589 8 8 8 8-3.589 8-8-3.589-8-8-8m0-2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2z" + /> + </g> + </svg> + </div> + <div + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + Test notice content + </div> + </div> + <div + class="components-notice__actions" + /> + </div> + <button + aria-label="Dismiss this notice" + class="components-button components-notice__dismiss has-icon" + type="button" + > + <svg + aria-hidden="true" + focusable="false" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" + /> + </svg> + </button> + </div> +</div> +`; + +exports[`Info InlineNotices renders with dismiss and icon and actions 1`] = ` +<div> + <div + class="wcpay-inline-notice wcpay-inline-info-notice components-notice is-info is-dismissible" + > + <div + class="components-notice__content" + > + <div + class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" + data-wp-c16t="true" + data-wp-component="Flex" + > + <div + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + <svg + class="gridicon gridicons-info-outline" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M13 9h-2V7h2v2zm0 2h-2v6h2v-6zm-1-7c-4.411 0-8 3.589-8 8s3.589 8 8 8 8-3.589 8-8-3.589-8-8-8m0-2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2z" + /> + </g> + </svg> + </div> + <div + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + Test notice content + <div + class="components-flex wcpay-inline-notice__content__actions css-azptjn-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" + data-wp-c16t="true" + data-wp-component="Flex" + > + <button + class="components-button wcpay-inline-notice__action" + type="button" + > + Button + </button> + <a + class="wcpay-inline-notice__action" + href="https://wordpress.com" + > + URL + </a> + </div> + </div> + </div> + <div + class="components-notice__actions" + /> + </div> + <button + aria-label="Dismiss this notice" + class="components-button components-notice__dismiss has-icon" + type="button" + > + <svg + aria-hidden="true" + focusable="false" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" + /> + </svg> + </button> + </div> +</div> +`; + +exports[`Info InlineNotices renders with no status and custom icon 1`] = ` +<div> + <div + class="wcpay-inline-notice wcpay-inline-undefined-notice components-notice is-info is-dismissible" + > + <div + class="components-notice__content" + > + <div + class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" + data-wp-c16t="true" + data-wp-component="Flex" + > + <div + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-undefined-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + <svg + class="gridicon gridicons-add" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" + /> + </g> + </svg> + </div> + <div + class="components-flex-item wcpay-inline-notice__content wcpay-inline-undefined-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + Test notice content + </div> + </div> + <div + class="components-notice__actions" + /> + </div> + <button + aria-label="Dismiss this notice" + class="components-button components-notice__dismiss has-icon" + type="button" + > + <svg + aria-hidden="true" + focusable="false" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z" + /> + </svg> + </button> + </div> +</div> +`; + +exports[`Info InlineNotices renders without dismiss and icon 1`] = ` +<div> + <div + class="wcpay-inline-notice wcpay-inline-info-notice components-notice is-info" + > + <div + class="components-notice__content" + > + <div + class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" + data-wp-c16t="true" + data-wp-component="Flex" + > + <div + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + Test notice content + </div> + </div> + <div + class="components-notice__actions" + /> + </div> + </div> +</div> +`; diff --git a/client/components/inline-notice/tests/index.test.tsx b/client/components/inline-notice/tests/index.test.tsx new file mode 100644 index 00000000000..23c26860cc8 --- /dev/null +++ b/client/components/inline-notice/tests/index.test.tsx @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import AddIcon from 'gridicons/dist/add'; + +/** + * Internal dependencies + */ +import InlineNotice from '..'; + +describe( 'Info InlineNotices renders', () => { + test( 'with dismiss', () => { + const { container } = render( + <InlineNotice + status="info" + className="wcpaytest-notice" + children={ 'Test notice content' } + isDismissible={ true } + /> + ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'with dismiss and icon', () => { + const { container } = render( + <InlineNotice + status="info" + icon + children={ 'Test notice content' } + isDismissible={ true } + /> + ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'with dismiss and icon and actions', () => { + const { container } = render( + <InlineNotice + status="info" + icon + children={ 'Test notice content' } + isDismissible={ true } + actions={ [ + { + label: 'Button', + onClick: jest.fn(), + }, + { + label: 'URL', + url: 'https://wordpress.com', + }, + ] } + /> + ); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'without dismiss and icon', () => { + const { container } = render( + <InlineNotice + status="info" + children={ 'Test notice content' } + isDismissible={ false } + /> + ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'with no status and custom icon', () => { + const { container } = render( + <InlineNotice + icon={ <AddIcon /> } + children={ 'Test notice content' } + /> + ); + expect( container ).toMatchSnapshot(); + } ); +} ); + +describe( 'Action click triggers callback', () => { + test( 'with dismiss and icon and actions', () => { + const onClickMock = jest.fn(); + const { getByText } = render( + <InlineNotice + status="warning" + children={ 'Test notice content' } + isDismissible={ true } + actions={ [ + { + label: 'Button', + onClick: onClickMock, + }, + { + label: 'URL', + url: 'https://wordpress.com', + }, + ] } + /> + ); + + fireEvent.click( getByText( 'Button' ) ); + expect( onClickMock ).toHaveBeenCalled(); + } ); + + test( 'With icon and multiple button actions', () => { + const onButtonClickOne = jest.fn(); + const onButtonClickTwo = jest.fn(); + const { getByText } = render( + <InlineNotice + status="warning" + children={ 'Test notice content' } + isDismissible={ true } + actions={ [ + { + label: 'Button one', + onClick: onButtonClickOne, + }, + { + label: 'Button two', + onClick: onButtonClickTwo, + }, + ] } + /> + ); + + expect( onButtonClickOne ).not.toHaveBeenCalled(); + expect( onButtonClickTwo ).not.toHaveBeenCalled(); + + // Click Button 1 + fireEvent.click( getByText( 'Button one' ) ); + expect( onButtonClickOne ).toHaveBeenCalled(); + expect( onButtonClickTwo ).not.toHaveBeenCalled(); + + // Click Button 1 + fireEvent.click( getByText( 'Button two' ) ); + expect( onButtonClickTwo ).toHaveBeenCalled(); + } ); +} ); + +describe( 'Dismiss click triggers callback', () => { + test( 'with dismiss and icon and actions', () => { + const onDismissMock = jest.fn(); + const { getByLabelText } = render( + <InlineNotice + status="error" + children={ 'Test notice content' } + isDismissible={ true } + onRemove={ onDismissMock } + /> + ); + + fireEvent.click( getByLabelText( 'Dismiss this notice' ) ); + expect( onDismissMock ).toHaveBeenCalled(); + } ); +} ); diff --git a/client/components/load-bar/style.scss b/client/components/load-bar/style.scss index 1329a0cce65..f3f7fb86d7f 100644 --- a/client/components/load-bar/style.scss +++ b/client/components/load-bar/style.scss @@ -8,7 +8,7 @@ display: block; height: 100%; width: 100%; - background-color: $blue-50; + background-color: var( --wp-admin-theme-color ); animation: wcpay-component-load-bar 3s ease-in-out infinite; transform-origin: 0 0; } diff --git a/client/components/radio-card/index.tsx b/client/components/radio-card/index.tsx index 580e3e06170..f8a2f9f74de 100644 --- a/client/components/radio-card/index.tsx +++ b/client/components/radio-card/index.tsx @@ -28,17 +28,23 @@ const RadioCard: React.FC< Props > = ( { onChange, className, } ) => { - const handleChange = ( event: React.ChangeEvent< HTMLInputElement > ) => - onChange( event.target.value ); - return ( <> { options.map( ( { label, icon, value, content } ) => { + const id = `radio-card-${ name }-${ value }`; const checked = value === selected; + const handleChange = () => onChange( value ); return ( - <label + <div + role="radio" + aria-checked={ checked } + tabIndex={ 0 } key={ value } + onClick={ handleChange } + onKeyDown={ ( event ) => { + if ( event.key === 'Enter' ) handleChange(); + } } className={ classNames( 'wcpay-component-radio-card', { checked }, @@ -47,17 +53,19 @@ const RadioCard: React.FC< Props > = ( { > <div className="wcpay-component-radio-card__label"> <input + id={ id } type="radio" name={ name } value={ value } checked={ !! checked } onChange={ handleChange } + tabIndex={ -1 } /> - { label } + <label htmlFor={ id }>{ label }</label> { icon } </div> { checked && content } - </label> + </div> ); } ) } </> diff --git a/client/components/radio-card/style.scss b/client/components/radio-card/style.scss index 2ce0dc954ab..f2e0f75766c 100644 --- a/client/components/radio-card/style.scss +++ b/client/components/radio-card/style.scss @@ -11,8 +11,9 @@ } &:hover, - &.checked { - box-shadow: 0 0 0 1px #007cba; + &.checked, + &:focus-visible { + box-shadow: 0 0 0 1.5px var( --wp-admin-theme-color ); } &__label { @@ -37,10 +38,15 @@ height: 20px; border-color: $gray-700; + &:checked { + border-color: var( --wp-admin-theme-color ); + } + &::before { width: 12px; height: 12px; margin: 3px; + background: var( --wp-admin-theme-color ); } &:focus { diff --git a/client/components/radio-card/test/__snapshots__/index.tsx.snap b/client/components/radio-card/test/__snapshots__/index.tsx.snap index dbeaea5fcb7..f4429bb7ffd 100644 --- a/client/components/radio-card/test/__snapshots__/index.tsx.snap +++ b/client/components/radio-card/test/__snapshots__/index.tsx.snap @@ -2,39 +2,57 @@ exports[`RadioCard Component renders RadioCard component with provided props 1`] = ` <div> - <label + <div + aria-checked="true" class="wcpay-component-radio-card checked" + role="radio" + tabindex="0" > <div class="wcpay-component-radio-card__label" > <input checked="" + id="radio-card-pizzas-pineapple" name="pizzas" + tabindex="-1" type="radio" value="pineapple" /> - Pineapple pizza + <label + for="radio-card-pizzas-pineapple" + > + Pineapple pizza + </label> <svg /> </div> <p> Sweet pizza </p> - </label> - <label + </div> + <div + aria-checked="false" class="wcpay-component-radio-card" + role="radio" + tabindex="0" > <div class="wcpay-component-radio-card__label" > <input + id="radio-card-pizzas-pizza" name="pizzas" + tabindex="-1" type="radio" value="pizza" /> - Pizza + <label + for="radio-card-pizzas-pizza" + > + Pizza + </label> <svg /> </div> - </label> + </div> </div> `; diff --git a/client/components/radio-card/test/index.tsx b/client/components/radio-card/test/index.tsx index 01d93fc6a97..de4050684ca 100644 --- a/client/components/radio-card/test/index.tsx +++ b/client/components/radio-card/test/index.tsx @@ -49,7 +49,7 @@ describe( 'RadioCard Component', () => { /> ); - user.click( screen.getByRole( 'radio', { name: /Pineapple/i } ) ); + user.click( screen.getByLabelText( /Pineapple/i ) ); expect( mockOnChange ).toHaveBeenCalledWith( 'pineapple' ); } ); } ); diff --git a/client/components/woopay/save-user/checkout-page-save-user.js b/client/components/woopay/save-user/checkout-page-save-user.js index 74ff82d3956..3337cb4cb1d 100644 --- a/client/components/woopay/save-user/checkout-page-save-user.js +++ b/client/components/woopay/save-user/checkout-page-save-user.js @@ -4,7 +4,6 @@ */ import React, { useEffect, useState, useCallback } from 'react'; import { __ } from '@wordpress/i18n'; -import { useDispatch } from '@wordpress/data'; // eslint-disable-next-line import/no-unresolved import { extensionCartUpdate } from '@woocommerce/blocks-checkout'; import { Icon, info } from '@wordpress/icons'; @@ -21,7 +20,6 @@ import Agreement from './agreement'; import Container from './container'; import useWooPayUser from '../hooks/use-woopay-user'; import useSelectedPaymentMethod from '../hooks/use-selected-payment-method'; -import { WC_STORE_CART } from '../../../checkout/constants'; import WooPayIcon from 'assets/images/woopay.svg?asset'; import wcpayTracks from 'tracks'; import './style.scss'; @@ -44,7 +42,6 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { const { isWCPayChosen, isNewPaymentTokenChosen } = useSelectedPaymentMethod( isBlocksCheckout ); - const cart = useDispatch( WC_STORE_CART ); const viewportWidth = window.document.documentElement.clientWidth; const viewportHeight = window.document.documentElement.clientHeight; @@ -73,9 +70,6 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { const sendExtensionData = useCallback( ( shouldClearData = false ) => { - const shippingPhone = document.getElementById( 'shipping-phone' ) - ?.value; - const billingPhone = document.getElementById( 'phone' )?.value; const data = shouldClearData ? {} : { @@ -93,23 +87,9 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { data: data, } ).then( () => { setUserDataSent( ! shouldClearData ); - // Cart returned from `extensionCartUpdate` clears these as these fields are not sent to backend by blocks when added. - // Setting them explicitly here to the previous user input. - cart.setShippingAddress( { - phone: shippingPhone, - } ); - cart.setBillingAddress( { - phone: billingPhone, - } ); } ); }, - [ - isSaveDetailsChecked, - phoneNumber, - cart, - viewportWidth, - viewportHeight, - ] + [ isSaveDetailsChecked, phoneNumber, viewportWidth, viewportHeight ] ); const handleCheckboxClick = ( e ) => { diff --git a/client/connect-account-page/modal/index.js b/client/connect-account-page/modal/index.js index 388c68aad69..c3cb728c043 100644 --- a/client/connect-account-page/modal/index.js +++ b/client/connect-account-page/modal/index.js @@ -15,7 +15,7 @@ import './style.scss'; const LearnMoreLink = ( props ) => ( <Link { ...props } - href="https://woocommerce.com/document/woocommerce-payments/compatibility/countries/" + href="https://woocommerce.com/document/woopayments/compatibility/countries/" target="_blank" rel="noopener noreferrer" type="external" diff --git a/client/connect-account-page/modal/test/__snapshots__/index.js.snap b/client/connect-account-page/modal/test/__snapshots__/index.js.snap index e27d740de2a..4610ba434e9 100644 --- a/client/connect-account-page/modal/test/__snapshots__/index.js.snap +++ b/client/connect-account-page/modal/test/__snapshots__/index.js.snap @@ -94,7 +94,7 @@ exports[`Onboarding: location check dialog renders correctly when continue butto <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/compatibility/countries/" + href="https://woocommerce.com/document/woopayments/compatibility/countries/" rel="noopener noreferrer" target="_blank" > @@ -216,7 +216,7 @@ exports[`Onboarding: location check dialog renders correctly when opened 1`] = ` <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/compatibility/countries/" + href="https://woocommerce.com/document/woopayments/compatibility/countries/" rel="noopener noreferrer" target="_blank" > diff --git a/client/data/files/action-types.ts b/client/data/files/action-types.ts new file mode 100644 index 00000000000..778562420ed --- /dev/null +++ b/client/data/files/action-types.ts @@ -0,0 +1,8 @@ +/** @format */ + +enum TYPES { + SET_FILE = 'SET_FILE', + SET_ERROR_FOR_FILES = 'SET_ERROR_FOR_FILES', +} + +export default TYPES; diff --git a/client/data/files/actions.ts b/client/data/files/actions.ts new file mode 100644 index 00000000000..25524e5a6eb --- /dev/null +++ b/client/data/files/actions.ts @@ -0,0 +1,31 @@ +/** @format */ + +/** + * Internal dependencies + */ +import ACTION_TYPES from './action-types'; +import type { + File, + UpdateFilesAction, + UpdateErrorForFilesAction, +} from './types'; +import { ApiError } from 'wcpay/types/errors'; + +export function updateFiles( id: string, data: File ): UpdateFilesAction { + return { + type: ACTION_TYPES.SET_FILE, + id, + data, + }; +} + +export function updateErrorForFiles( + id: string, + error: ApiError +): UpdateErrorForFilesAction { + return { + type: ACTION_TYPES.SET_ERROR_FOR_FILES, + id, + error, + }; +} diff --git a/client/data/files/hooks.ts b/client/data/files/hooks.ts new file mode 100644 index 00000000000..c91065146a8 --- /dev/null +++ b/client/data/files/hooks.ts @@ -0,0 +1,37 @@ +/** @format */ + +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import type { File, FileResponse } from './types'; +import { STORE_NAME } from '../constants'; + +export const useFiles = ( id: string ): FileResponse => + useSelect( + ( select ) => { + const selectors = select( STORE_NAME ); + + const { + getFile, + getFileError, + isResolving, + hasFinishedResolution, + } = selectors; + + const file: File = getFile( id ); + + return { + file: file || ( {} as File ), + error: getFileError( id ), + isLoading: + isResolving( 'getFile', [ id ] ) || + ! hasFinishedResolution( 'getFile', [ id ] ), + }; + }, + [ id ] + ); diff --git a/client/data/files/index.ts b/client/data/files/index.ts new file mode 100644 index 00000000000..c524cca8b05 --- /dev/null +++ b/client/data/files/index.ts @@ -0,0 +1,12 @@ +/** @format */ + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; + +export { reducer, selectors, actions, resolvers }; +export * from './hooks'; diff --git a/client/data/files/reducer.ts b/client/data/files/reducer.ts new file mode 100644 index 00000000000..e3b1ff4de06 --- /dev/null +++ b/client/data/files/reducer.ts @@ -0,0 +1,44 @@ +/** @format */ + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { + UpdateErrorForFilesAction, + FilesState, + FilesActions, + UpdateFilesAction, +} from './types'; + +const defaultState = {}; + +const receiveFiles = ( + state: FilesState = defaultState, + action: FilesActions +): FilesState => { + const { type, id } = action; + + switch ( type ) { + case TYPES.SET_FILE: + return { + ...state, + [ id ]: { + ...state[ id ], + data: ( action as UpdateFilesAction ).data, + }, + }; + case TYPES.SET_ERROR_FOR_FILES: + return { + ...state, + [ id ]: { + ...state[ id ], + error: ( action as UpdateErrorForFilesAction ).error, + }, + }; + default: + return state; + } +}; + +export default receiveFiles; diff --git a/client/data/files/resolvers.ts b/client/data/files/resolvers.ts new file mode 100644 index 00000000000..a4ade1ccef3 --- /dev/null +++ b/client/data/files/resolvers.ts @@ -0,0 +1,37 @@ +/** @format */ + +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { updateFiles, updateErrorForFiles } from './actions'; +import { File } from './types'; +import { ApiError } from 'wcpay/types/errors'; + +/** + * Retrieve a single file from the files API. + * + * @param {string} id Identifier for specified file to retrieve. + */ +export function* getFile( id: string ): Generator< unknown > { + try { + const result = yield apiFetch( { + path: `${ NAMESPACE }/file/${ id }/details`, + } ); + yield updateFiles( id, result as File ); + } catch ( e ) { + yield controls.dispatch( + 'core/notices', + 'createErrorNotice', + __( 'Error retrieving file.', 'woocommerce-payments' ) + ); + yield updateErrorForFiles( id, e as ApiError ); + } +} diff --git a/client/data/files/selectors.ts b/client/data/files/selectors.ts new file mode 100644 index 00000000000..47cb27c1bb3 --- /dev/null +++ b/client/data/files/selectors.ts @@ -0,0 +1,20 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { State } from 'wcpay/data/types'; +import { File } from './types'; +import { ApiError } from 'wcpay/types/errors'; + +export const getFile = ( { files }: State, id: string ): File => { + const file = files?.[ id ]; + + return file?.data || ( {} as File ); +}; + +export const getFileError = ( { files }: State, id: string ): ApiError => { + const file = files?.[ id ]; + + return file?.error || ( {} as ApiError ); +}; diff --git a/client/data/files/types.d.ts b/client/data/files/types.d.ts new file mode 100644 index 00000000000..2c011de77c8 --- /dev/null +++ b/client/data/files/types.d.ts @@ -0,0 +1,73 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { ApiError } from 'wcpay/types/errors'; +import ACTION_TYPES from './action-types'; + +export interface File { + /** + * Unique identifier for the file, expected to be prefixed by file_ + */ + id: string; + /** + * The purpose for file. eg 'dispute_evidence'. + */ + purpose: string; + /** + * The filetype 'pdf' 'csv' 'jpg' etc. + */ + type: string; + /** + * A filename for the file, suitable for saving to a filesystem. + */ + filename: string; + /** + * The size in bytes. + */ + size: number; + /** + * A user friendly title for the file. + */ + title: string | null; +} + +export interface FileDownload { + /** + * The file mime-type. + */ + content_type: string; + /** + * The file content, base64 encoded. + */ + file_content: string; +} + +export interface FileResponse { + isLoading: boolean; + file?: File; + fileError?: ApiError; +} + +export interface UpdateFilesAction { + type: ACTION_TYPES.SET_FILE; + id: string; + data: File; +} + +export interface UpdateErrorForFilesAction { + type: ACTION_TYPES.SET_ERROR_FOR_FILES; + id: string; + error: ApiError; +} + +export interface FilesState { + [ key: string ]: { + id: string; + data?: File; + error?: ApiError; + }; +} + +export type FilesActions = UpdateFilesAction | UpdateErrorForFilesAction; diff --git a/client/data/index.ts b/client/data/index.ts index f2f4d081d78..0d42f41ce93 100644 --- a/client/data/index.ts +++ b/client/data/index.ts @@ -24,3 +24,4 @@ export * from './capital/hooks'; export * from './documents/hooks'; export * from './payment-intents/hooks'; export * from './authorizations/hooks'; +export * from './files/hooks'; diff --git a/client/data/multi-currency/hooks.js b/client/data/multi-currency/hooks.js index 8f7c1e12ed0..aa39e24ee6f 100644 --- a/client/data/multi-currency/hooks.js +++ b/client/data/multi-currency/hooks.js @@ -10,13 +10,6 @@ export const useCurrencies = () => useSelect( ( select ) => { const { getCurrencies, isResolving } = select( STORE_NAME ); - if ( wcpaySettings.isMultiCurrencyEnabled !== '1' ) { - return { - currencies: {}, - isLoading: false, - }; - } - return { currencies: getCurrencies(), isLoading: isResolving( 'getCurrencies', [] ), diff --git a/client/data/settings/actions.js b/client/data/settings/actions.js index 59a73f7c225..6444df92b4b 100644 --- a/client/data/settings/actions.js +++ b/client/data/settings/actions.js @@ -123,6 +123,22 @@ export function updateAccountStatementDescriptor( accountStatementDescriptor ) { } ); } +export function updateAccountStatementDescriptorKanji( + accountStatementDescriptorKanji +) { + return updateSettingsValues( { + account_statement_descriptor_kanji: accountStatementDescriptorKanji, + } ); +} + +export function updateAccountStatementDescriptorKana( + accountStatementDescriptorKana +) { + return updateSettingsValues( { + account_statement_descriptor_kana: accountStatementDescriptorKana, + } ); +} + export function updateAccountBusinessName( accountBusinessName ) { return updateSettingsValues( { account_business_name: accountBusinessName, @@ -253,3 +269,31 @@ export function updateAdvancedFraudProtectionSettings( settings ) { advanced_fraud_protection_settings: settings, } ); } + +export function updateIsStripeBillingEnabled( isEnabled ) { + return updateSettingsValues( { is_stripe_billing_enabled: isEnabled } ); +} + +export function* submitStripeBillingSubscriptionMigration() { + try { + yield dispatch( STORE_NAME ).startResolution( + 'scheduleStripeBillingMigration' + ); + + yield apiFetch( { + path: `${ NAMESPACE }/settings/schedule-stripe-billing-migration`, + method: 'post', + } ); + } catch ( e ) { + yield dispatch( 'core/notices' ).createErrorNotice( + __( + 'Error starting the Stripe Billing migration.', + 'woocommerce-payments' + ) + ); + } + + yield dispatch( STORE_NAME ).finishResolution( + 'scheduleStripeBillingMigration' + ); +} diff --git a/client/data/settings/hooks.js b/client/data/settings/hooks.js index 9bea7a71c5e..b8bf25e730a 100644 --- a/client/data/settings/hooks.js +++ b/client/data/settings/hooks.js @@ -154,17 +154,13 @@ export const useWCPaySubscriptions = () => { const { getIsWCPaySubscriptionsEnabled, getIsWCPaySubscriptionsEligible, - getIsSubscriptionsPluginActive, } = select( STORE_NAME ); const isWCPaySubscriptionsEnabled = getIsWCPaySubscriptionsEnabled(); const isWCPaySubscriptionsEligible = getIsWCPaySubscriptionsEligible(); - const isSubscriptionsPluginActive = getIsSubscriptionsPluginActive(); - return [ isWCPaySubscriptionsEnabled, isWCPaySubscriptionsEligible, - isSubscriptionsPluginActive, updateIsWCPaySubscriptionsEnabled, ]; }, @@ -188,6 +184,38 @@ export const useAccountStatementDescriptor = () => { ); }; +export const useAccountStatementDescriptorKanji = () => { + const { updateAccountStatementDescriptorKanji } = useDispatch( STORE_NAME ); + + return useSelect( + ( select ) => { + const { getAccountStatementDescriptorKanji } = select( STORE_NAME ); + + return [ + getAccountStatementDescriptorKanji(), + updateAccountStatementDescriptorKanji, + ]; + }, + [ updateAccountStatementDescriptorKanji ] + ); +}; + +export const useAccountStatementDescriptorKana = () => { + const { updateAccountStatementDescriptorKana } = useDispatch( STORE_NAME ); + + return useSelect( + ( select ) => { + const { getAccountStatementDescriptorKana } = select( STORE_NAME ); + + return [ + getAccountStatementDescriptorKana(), + updateAccountStatementDescriptorKana, + ]; + }, + [ updateAccountStatementDescriptorKana ] + ); +}; + export const useAccountBusinessName = () => { const { updateAccountBusinessName } = useDispatch( STORE_NAME ); @@ -577,3 +605,44 @@ export const useWooPayShowIncompatibilityNotice = () => { return getShowWooPayIncompatibilityNotice(); } ); }; + +export const useStripeBilling = () => { + const { updateIsStripeBillingEnabled } = useDispatch( STORE_NAME ); + + return useSelect( + ( select ) => { + const { getIsStripeBillingEnabled } = select( STORE_NAME ); + + return [ + getIsStripeBillingEnabled(), + updateIsStripeBillingEnabled, + ]; + }, + [ updateIsStripeBillingEnabled ] + ); +}; + +export const useStripeBillingMigration = () => { + const { submitStripeBillingSubscriptionMigration } = useDispatch( + STORE_NAME + ); + + return useSelect( ( select ) => { + const { getStripeBillingSubscriptionCount } = select( STORE_NAME ); + const { getIsStripeBillingMigrationInProgress } = select( STORE_NAME ); + const { isResolving } = select( STORE_NAME ); + const hasResolved = select( STORE_NAME ).hasFinishedResolution( + 'scheduleStripeBillingMigration' + ); + const { getStripeBillingMigratedCount } = select( STORE_NAME ); + + return [ + getIsStripeBillingMigrationInProgress(), + getStripeBillingMigratedCount(), + getStripeBillingSubscriptionCount(), + submitStripeBillingSubscriptionMigration, + isResolving( 'scheduleStripeBillingMigration' ), + hasResolved, + ]; + }, [] ); +}; diff --git a/client/data/settings/selectors.js b/client/data/settings/selectors.js index e922a4a944d..41facc3ebb0 100644 --- a/client/data/settings/selectors.js +++ b/client/data/settings/selectors.js @@ -52,6 +52,14 @@ export const getAccountStatementDescriptor = ( state ) => { return getSettings( state ).account_statement_descriptor || ''; }; +export const getAccountStatementDescriptorKanji = ( state ) => { + return getSettings( state ).account_statement_descriptor_kanji || ''; +}; + +export const getAccountStatementDescriptorKana = ( state ) => { + return getSettings( state ).account_statement_descriptor_kana || ''; +}; + export const getAccountBusinessName = ( state ) => { return getSettings( state ).account_business_name || ''; }; @@ -225,3 +233,19 @@ export const getAdvancedFraudProtectionSettings = ( state ) => { export const getShowWooPayIncompatibilityNotice = ( state ) => { return getSettings( state ).show_woopay_incompatibility_notice || false; }; + +export const getIsStripeBillingEnabled = ( state ) => { + return getSettings( state ).is_stripe_billing_enabled || false; +}; + +export const getIsStripeBillingMigrationInProgress = ( state ) => { + return getSettings( state ).is_migrating_stripe_billing || false; +}; + +export const getStripeBillingSubscriptionCount = ( state ) => { + return getSettings( state ).stripe_billing_subscription_count || 0; +}; + +export const getStripeBillingMigratedCount = ( state ) => { + return getSettings( state ).stripe_billing_migrated_count || 0; +}; diff --git a/client/data/store.js b/client/data/store.js index 8af70e68c4b..13e4c90733b 100644 --- a/client/data/store.js +++ b/client/data/store.js @@ -20,6 +20,7 @@ import * as capital from './capital'; import * as documents from './documents'; import * as paymentIntents from './payment-intents'; import * as authorizations from './authorizations'; +import * as files from './files'; // Extracted into wrapper function to facilitate testing. export const initStore = () => @@ -37,6 +38,7 @@ export const initStore = () => documents: documents.reducer, paymentIntents: paymentIntents.reducer, authorizations: authorizations.reducer, + files: files.reducer, } ), actions: { ...deposits.actions, @@ -51,6 +53,7 @@ export const initStore = () => ...documents.actions, ...paymentIntents.actions, ...authorizations.actions, + ...files.actions, }, controls, selectors: { @@ -66,6 +69,7 @@ export const initStore = () => ...documents.selectors, ...paymentIntents.selectors, ...authorizations.selectors, + ...files.selectors, }, resolvers: { ...deposits.resolvers, @@ -80,5 +84,6 @@ export const initStore = () => ...documents.resolvers, ...paymentIntents.resolvers, ...authorizations.resolvers, + ...files.resolvers, }, } ); diff --git a/client/data/types.d.ts b/client/data/types.d.ts index 4fe91140b8f..ae79164fb81 100644 --- a/client/data/types.d.ts +++ b/client/data/types.d.ts @@ -5,8 +5,10 @@ */ import { CapitalState } from './capital/types'; import { PaymentIntentsState } from './payment-intents/types'; +import { FilesState } from './files/types'; export interface State { capital?: CapitalState; paymentIntents?: PaymentIntentsState; + files?: FilesState; } diff --git a/client/deposits/instant-deposits/modal.tsx b/client/deposits/instant-deposits/modal.tsx index c99d9a2a5c6..25b2783a348 100644 --- a/client/deposits/instant-deposits/modal.tsx +++ b/client/deposits/instant-deposits/modal.tsx @@ -29,7 +29,7 @@ const InstantDepositModal: React.FC< InstantDepositModalProps > = ( { inProgress, } ) => { const learnMoreHref = - 'https://woocommerce.com/document/woocommerce-payments/deposits/instant-deposits/'; + 'https://woocommerce.com/document/woopayments/deposits/instant-deposits/'; const feePercentage = `${ percentage }%`; const description = createInterpolateElement( /* translators: %s: amount representing the fee percentage, <a>: instant payout doc URL */ diff --git a/client/deposits/instant-deposits/test/__snapshots__/index.tsx.snap b/client/deposits/instant-deposits/test/__snapshots__/index.tsx.snap index 2ecd34561dc..6d9772123f8 100644 --- a/client/deposits/instant-deposits/test/__snapshots__/index.tsx.snap +++ b/client/deposits/instant-deposits/test/__snapshots__/index.tsx.snap @@ -69,7 +69,7 @@ exports[`Instant deposit button and modal modal renders correctly 1`] = ` <p> Need cash in a hurry? Instant deposits are available within 30 minutes for a nominal 1.5% service fee. <a - href="https://woocommerce.com/document/woocommerce-payments/deposits/instant-deposits/" + href="https://woocommerce.com/document/woopayments/deposits/instant-deposits/" rel="noopener noreferrer" target="_blank" > diff --git a/client/disputes/filters/config.ts b/client/disputes/filters/config.ts index e24dc1affd1..3d0d1ab5f63 100644 --- a/client/disputes/filters/config.ts +++ b/client/disputes/filters/config.ts @@ -36,6 +36,11 @@ export const disputeAwaitingResponseStatuses = [ 'warning_needs_response', ]; +export const disputeUnderReviewStatuses = [ + 'under_review', + 'warning_under_review', +]; + export const filters: [ DisputesFilterType, DisputesFilterType ] = [ { label: __( 'Dispute currency', 'woocommerce-payments' ), diff --git a/client/disputes/index.tsx b/client/disputes/index.tsx index f28732ffd1b..3ba2782f82f 100644 --- a/client/disputes/index.tsx +++ b/client/disputes/index.tsx @@ -35,12 +35,12 @@ import { reasons } from './strings'; import { formatStringValue } from 'utils'; import { formatExplicitCurrency } from 'utils/currency'; import DisputesFilters from './filters'; -import { disputeAwaitingResponseStatuses } from './filters/config'; import DownloadButton from 'components/download-button'; import disputeStatusMapping from 'components/dispute-status-chip/mappings'; import { CachedDispute, DisputesTableHeader } from 'wcpay/types/disputes'; import { getDisputesCSV } from 'wcpay/data/disputes/resolvers'; import { applyThousandSeparator } from 'wcpay/utils'; +import { isAwaitingResponse } from 'wcpay/disputes/utils'; import './style.scss'; @@ -154,10 +154,7 @@ const getHeaders = ( sortColumn?: string ): DisputesTableHeader[] => [ */ const smartDueDate = ( dispute: CachedDispute ) => { // if dispute is not awaiting response, return an empty string. - if ( - dispute.due_by === '' || - ! disputeAwaitingResponseStatuses.includes( dispute.status ) - ) { + if ( dispute.due_by === '' || ! isAwaitingResponse( dispute.status ) ) { return ''; } // Get current time in UTC. @@ -224,9 +221,7 @@ export const DisputesList = (): JSX.Element => { const reasonDisplay = reasonMapping ? reasonMapping.display : formatStringValue( dispute.reason ); - const needsResponse = disputeAwaitingResponseStatuses.includes( - dispute.status - ); + const needsResponse = isAwaitingResponse( dispute.status ); const data: { [ key: string ]: { value: number | string; diff --git a/client/disputes/strings.ts b/client/disputes/strings.ts index e1cf611033a..319e086e005 100644 --- a/client/disputes/strings.ts +++ b/client/disputes/strings.ts @@ -16,6 +16,7 @@ export const reasons: Record< summary?: string[]; required?: string[]; respond?: string[]; + claim?: string; } > = { bank_cannot_process: { @@ -58,6 +59,10 @@ export const reasons: Record< 'woocommerce-payments' ), ], + claim: __( + 'The cardholder claims a credit was not processed.', + 'woocommerce-payments' + ), }, customer_initiated: { display: __( 'Customer initiated', 'woocommerce-payments' ), @@ -107,6 +112,10 @@ export const reasons: Record< 'woocommerce-payments' ), ], + claim: __( + 'The cardholder claims this is a duplicate transaction.', + 'woocommerce-payments' + ), }, fraudulent: { display: __( 'Transaction unauthorized', 'woocommerce-payments' ), @@ -146,6 +155,10 @@ export const reasons: Record< 'woocommerce-payments' ), ], + claim: __( + 'The cardholder claims this is an unauthorized transaction.', + 'woocommerce-payments' + ), }, general: { display: __( 'General', 'woocommerce-payments' ), @@ -202,6 +215,10 @@ export const reasons: Record< 'woocommerce-payments' ), ], + claim: __( + 'The cardholder claims the product was not received.', + 'woocommerce-payments' + ), }, product_unacceptable: { display: __( 'Product unacceptable', 'woocommerce-payments' ), @@ -249,6 +266,10 @@ export const reasons: Record< 'woocommerce-payments' ), ], + claim: __( + 'The cardholder claims the product was unacceptable.', + 'woocommerce-payments' + ), }, subscription_canceled: { display: __( 'Subscription canceled', 'woocommerce-payments' ), @@ -288,6 +309,10 @@ export const reasons: Record< 'woocommerce-payments' ), ], + claim: __( + 'The cardholder claims a subscription was canceled.', + 'woocommerce-payments' + ), }, unrecognized: { display: __( 'Unrecognized', 'woocommerce-payments' ), diff --git a/client/disputes/utils.ts b/client/disputes/utils.ts index 205d9ea05a0..aa8ddaf65f0 100644 --- a/client/disputes/utils.ts +++ b/client/disputes/utils.ts @@ -10,8 +10,13 @@ import moment from 'moment'; import type { CachedDispute, Dispute, + DisputeStatus, EvidenceDetails, } from 'wcpay/types/disputes'; +import { + disputeAwaitingResponseStatuses, + disputeUnderReviewStatuses, +} from 'wcpay/disputes/filters/config'; interface IsDueWithinProps { dueBy: CachedDispute[ 'due_by' ] | EvidenceDetails[ 'due_by' ]; @@ -50,6 +55,16 @@ export const isDueWithin = ( { dueBy, days }: IsDueWithinProps ): boolean => { return isWithinDays && ! isPastDue; }; +export const isAwaitingResponse = ( + status: DisputeStatus | string +): boolean => { + return disputeAwaitingResponseStatuses.includes( status ); +}; + +export const isUnderReview = ( status: DisputeStatus | string ): boolean => { + return disputeUnderReviewStatuses.includes( status ); +}; + export const isInquiry = ( dispute: Dispute | CachedDispute ): boolean => { // Inquiry dispute statuses are one of `warning_needs_response`, `warning_under_review` or `warning_closed`. return dispute.status.startsWith( 'warning' ); diff --git a/client/globals.d.ts b/client/globals.d.ts index bdb82e4354c..293db5f0e0d 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -19,6 +19,7 @@ declare global { }; fraudServices: unknown[]; testMode: boolean; + devMode: boolean; isJetpackConnected: boolean; isJetpackIdcActive: boolean; accountStatus: { @@ -84,6 +85,7 @@ declare global { isEnabled: boolean; isComplete: boolean; }; + enabledPaymentMethods: string[]; accountDefaultCurrency: string; isFRTReviewFeatureActive: boolean; frtDiscoverBannerSettings: string; @@ -105,8 +107,12 @@ declare global { id: string; description: string; tc_url: string; + task_header_content?: string; + task_badge?: string; }; isWooPayStoreCountryAvailable: boolean; + isSubscriptionsPluginActive: boolean; + isStripeBillingEligible: boolean; }; const wcTracks: any; diff --git a/client/index.js b/client/index.js index 9b6ebd092d4..acb980c353e 100644 --- a/client/index.js +++ b/client/index.js @@ -296,8 +296,8 @@ addFilter( const { showUpdateDetailsTask, wpcomReconnectUrl } = wcpaySettings; const wcPayTasks = getTasks( { - showUpdateDetailsTask, - wpcomReconnectUrl, + showUpdateDetailsTask: showUpdateDetailsTask, + wpcomReconnectUrl: wpcomReconnectUrl, } ); return [ ...tasks, ...wcPayTasks ]; diff --git a/client/multi-currency/blocks/currency-switcher.js b/client/multi-currency/blocks/currency-switcher.js index 02a23a68f36..5dee2d130be 100644 --- a/client/multi-currency/blocks/currency-switcher.js +++ b/client/multi-currency/blocks/currency-switcher.js @@ -130,6 +130,16 @@ registerBlockType( 'woocommerce-payments/multi-currency-switcher', { // eslint-disable-next-line react-hooks/rules-of-hooks const blockProps = useBlockProps(); + /** + * WP Emoji replaces the flag emoji with an image if it's not natively + * supported by the browser. This behavior is problematic on Windows + * because it renders an <img> tag inside the <option>, which can lead to crashes. + * We need to guarantee that the OS supports flag emojis before rendering it. + */ + const supportsFlagEmoji = window._wpemojiSettings + ? window._wpemojiSettings.supports?.flag + : true; + const onChangeFlag = ( newFlag ) => { setAttributes( { flag: newFlag } ); }; @@ -310,7 +320,7 @@ registerBlockType( 'woocommerce-payments/multi-currency-switcher', { key={ enabledCurrencies[ code ].id } value={ enabledCurrencies[ code ].code } > - { flag + { supportsFlagEmoji && flag ? enabledCurrencies[ code ].flag + ' ' : '' } { symbol && diff --git a/client/multi-currency/multi-currency-settings/enabled-currencies-list/index.js b/client/multi-currency/multi-currency-settings/enabled-currencies-list/index.js index d66585a26ee..8d08b90ef11 100644 --- a/client/multi-currency/multi-currency-settings/enabled-currencies-list/index.js +++ b/client/multi-currency/multi-currency-settings/enabled-currencies-list/index.js @@ -25,7 +25,7 @@ import SettingsSection from 'wcpay/settings/settings-section'; const EnabledCurrenciesSettingsDescription = () => { const LEARN_MORE_URL = - 'https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/'; + 'https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/#enabled-currencies'; return ( <> diff --git a/client/multi-currency/multi-currency-settings/enabled-currencies-list/test/__snapshots__/index.js.snap b/client/multi-currency/multi-currency-settings/enabled-currencies-list/test/__snapshots__/index.js.snap index ffc13bc4aba..f4a0e2195bc 100644 --- a/client/multi-currency/multi-currency-settings/enabled-currencies-list/test/__snapshots__/index.js.snap +++ b/client/multi-currency/multi-currency-settings/enabled-currencies-list/test/__snapshots__/index.js.snap @@ -14,7 +14,7 @@ exports[`Multi-Currency enabled currencies list Enabled currencies list renders <p> Accept payments in multiple currencies. Prices are converted based on exchange rates and rounding rules. <a - href="https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/" + href="https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/#enabled-currencies" rel="noreferrer" target="_blank" > diff --git a/client/multi-currency/multi-currency-settings/store-settings/index.js b/client/multi-currency/multi-currency-settings/store-settings/index.js index f5f23d82054..ccb8b8f4818 100644 --- a/client/multi-currency/multi-currency-settings/store-settings/index.js +++ b/client/multi-currency/multi-currency-settings/store-settings/index.js @@ -18,7 +18,7 @@ import PreviewModal from 'wcpay/multi-currency/preview-modal'; const StoreSettingsDescription = () => { const LEARN_MORE_URL = - 'https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/'; + 'https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/#store-settings'; return ( <> diff --git a/client/multi-currency/multi-currency-settings/store-settings/test/__snapshots__/index.test.js.snap b/client/multi-currency/multi-currency-settings/store-settings/test/__snapshots__/index.test.js.snap index 74491d62932..b0ddd6f2af1 100644 --- a/client/multi-currency/multi-currency-settings/store-settings/test/__snapshots__/index.test.js.snap +++ b/client/multi-currency/multi-currency-settings/store-settings/test/__snapshots__/index.test.js.snap @@ -14,7 +14,7 @@ exports[`Multi-Currency store settings store settings task renders correctly: sn <p> Store settings allow your customers to choose which currency they would like to use when shopping at your store. <a - href="https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/" + href="https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/#store-settings" rel="noreferrer" target="_blank" > diff --git a/client/multi-currency/single-currency-settings/index.js b/client/multi-currency/single-currency-settings/index.js index 39c3b94ef0e..8285f206dd8 100644 --- a/client/multi-currency/single-currency-settings/index.js +++ b/client/multi-currency/single-currency-settings/index.js @@ -3,6 +3,7 @@ * External dependencies */ import React, { useContext, useEffect, useState } from 'react'; +import { dateI18n } from '@wordpress/date'; import { sprintf, __ } from '@wordpress/i18n'; import SettingsLayout from 'wcpay/settings/settings-layout'; import SettingsSection from 'wcpay/settings/settings-section'; @@ -20,7 +21,6 @@ import { decimalCurrencyRoundingOptions, zeroDecimalCurrencyCharmOptions, zeroDecimalCurrencyRoundingOptions, - toMoment, } from './constants'; import { useCurrencies, @@ -101,14 +101,14 @@ const SingleCurrencySettings = () => { } }, [ currencySettings, currency, initialPriceRoundingType ] ); + const dateFormat = storeSettings.date_format ?? 'M j, Y'; + const timeFormat = storeSettings.time_format ?? 'g:iA'; + const formattedLastUpdatedDateTime = targetCurrency - ? moment - .unix( targetCurrency.last_updated ) - .format( - toMoment( storeSettings.date_format ?? 'F j, Y' ) + - ' ' + - toMoment( storeSettings.time_format ?? 'HH:mm' ) - ) + ? dateI18n( + `${ dateFormat } ${ timeFormat }`, + moment.unix( targetCurrency.last_updated ).toISOString() + ) : ''; const CurrencySettingsDescription = () => ( @@ -279,11 +279,16 @@ const SingleCurrencySettings = () => { exchangeRateType === 'manual' } - onChange={ () => + onChange={ () => { setExchangeRateType( 'manual' - ) - } + ); + setManualRate( + manualRate + ? manualRate + : targetCurrency.rate + ); + } } /> <h4> { __( @@ -412,7 +417,7 @@ const SingleCurrencySettings = () => { onClick={ () => { open( /* eslint-disable-next-line max-len */ - 'https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/#price-rounding', + 'https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/#price-rounding', '_blank' ); } } @@ -477,7 +482,7 @@ const SingleCurrencySettings = () => { onClick={ () => { open( /* eslint-disable-next-line max-len */ - 'https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/#charm-pricing', + 'https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/#charm-pricing', '_blank' ); } } diff --git a/client/multi-currency/single-currency-settings/test/__snapshots__/index.test.js.snap b/client/multi-currency/single-currency-settings/test/__snapshots__/index.test.js.snap index 867b5783771..69b7ed61b74 100644 --- a/client/multi-currency/single-currency-settings/test/__snapshots__/index.test.js.snap +++ b/client/multi-currency/single-currency-settings/test/__snapshots__/index.test.js.snap @@ -82,7 +82,7 @@ exports[`Single currency settings screen Page renders correctly 1`] = ` <p class="single-currency-settings-description single-currency-settings-description-inset" > - Current rate: 1 USD = 0.826381 EUR (Last updated: September 24, 2021 01:14) + Current rate: 1 USD = 0.826381 EUR (Last updated: Sep 24, 2021 1:14AM) </p> </label> </li> diff --git a/client/onboarding/restored-state-banner.tsx b/client/onboarding/restored-state-banner.tsx index 9a0e3ffde65..8ffef4fc431 100644 --- a/client/onboarding/restored-state-banner.tsx +++ b/client/onboarding/restored-state-banner.tsx @@ -2,7 +2,6 @@ * External dependencies */ import React from 'react'; -import { info } from '@wordpress/icons'; /** * Internal dependencies @@ -15,10 +14,9 @@ const RestoredStateBanner: React.FC = () => { if ( hidden || ! wcpaySettings.onboardingFlowState ) return null; return ( <BannerNotice - className="restored-state-banner" + icon status="info" - icon={ info } - isDismissible={ true } + className="restored-state-banner" onRemove={ () => setHidden( true ) } > { strings.restoredState } diff --git a/client/onboarding/steps/mode-choice.tsx b/client/onboarding/steps/mode-choice.tsx index 3065560dbbd..00af3d247be 100644 --- a/client/onboarding/steps/mode-choice.tsx +++ b/client/onboarding/steps/mode-choice.tsx @@ -13,8 +13,16 @@ import RadioCard from 'components/radio-card'; import { useStepperContext } from 'components/stepper'; import { trackModeSelected } from '../tracking'; import strings from '../strings'; +import InlineNotice from 'components/inline-notice'; + +const DevModeNotice = () => ( + <InlineNotice icon status="warning" isDismissible={ false }> + { strings.steps.mode.devModeNotice } + </InlineNotice> +); const ModeChoice: React.FC = () => { + const { devMode } = wcpaySettings; const liveStrings = strings.steps.mode.live; const testStrings = strings.steps.mode.test; @@ -35,6 +43,7 @@ const ModeChoice: React.FC = () => { return ( <> + { devMode && <DevModeNotice /> } <RadioCard name="onboarding-mode" selected={ selected } diff --git a/client/onboarding/steps/personal-details.tsx b/client/onboarding/steps/personal-details.tsx index 292f68a0c71..4941ca9df29 100644 --- a/client/onboarding/steps/personal-details.tsx +++ b/client/onboarding/steps/personal-details.tsx @@ -3,14 +3,13 @@ */ import React from 'react'; import { Flex, FlexBlock } from '@wordpress/components'; -import { info } from '@wordpress/icons'; /** * Internal dependencies */ import strings from '../strings'; import { OnboardingTextField, OnboardingPhoneNumberField } from '../form'; -import BannerNotice from 'components/banner-notice'; +import InlineNotice from 'components/inline-notice'; const PersonalDetails: React.FC = () => { return ( @@ -25,9 +24,14 @@ const PersonalDetails: React.FC = () => { </Flex> <OnboardingTextField name="email" /> <OnboardingPhoneNumberField name="phone" /> - <BannerNotice status="info" icon={ info } isDismissible={ false }> + <InlineNotice + status="info" + className="personal-details-notice" + icon + isDismissible={ false } + > { strings.steps.personal.notice } - </BannerNotice> + </InlineNotice> </> ); }; diff --git a/client/onboarding/steps/test/mode-choice.tsx b/client/onboarding/steps/test/mode-choice.tsx index 0b55a787ca7..8c081182324 100644 --- a/client/onboarding/steps/test/mode-choice.tsx +++ b/client/onboarding/steps/test/mode-choice.tsx @@ -22,11 +22,16 @@ jest.mock( 'components/stepper', () => ( { declare const global: { wcpaySettings: { connectUrl: string; + devMode: boolean; }; }; describe( 'ModeChoice', () => { - it( 'displays test and live radio cards', () => { + it( 'displays test and live radio cards, notice for dev mode', () => { + global.wcpaySettings = { + connectUrl: 'https://wcpay.test/connect', + devMode: true, + }; render( <ModeChoice /> ); expect( @@ -35,6 +40,11 @@ describe( 'ModeChoice', () => { expect( screen.getByText( strings.steps.mode.test.label ) ).toBeInTheDocument(); + expect( + screen.getByText( + 'Dev mode is enabled, only test accounts will be created. If you want to process live transactions, please disable it.' + ) + ).toBeInTheDocument(); } ); it( 'calls nextStep by clicking continue when `live` is selected', () => { @@ -50,6 +60,7 @@ describe( 'ModeChoice', () => { it( 'redirects to `connectUrl` with `test_mode` enabled by clicking continue button when `test` is selected', () => { global.wcpaySettings = { connectUrl: 'https://wcpay.test/connect', + devMode: false, }; Object.defineProperty( window, 'location', { configurable: true, diff --git a/client/onboarding/strings.tsx b/client/onboarding/strings.tsx index aa996532fd7..1037b1c0639 100644 --- a/client/onboarding/strings.tsx +++ b/client/onboarding/strings.tsx @@ -3,6 +3,8 @@ * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; +import interpolateComponents from '@automattic/interpolate-components'; +import React from 'react'; export default { steps: { @@ -39,6 +41,25 @@ export default { 'WooPayments' ), }, + devModeNotice: interpolateComponents( { + mixedString: __( + 'Dev mode is enabled, only test accounts will be created. If you want to process live transactions, please disable it. {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'woocommerce-payments' + ), + components: { + learnMoreLink: ( + // Link content is in the format string above. Consider disabling jsx-a11y/anchor-has-content. + // eslint-disable-next-line jsx-a11y/anchor-has-content + <a + href={ + 'https://woocommerce.com/document/woopayments/testing-and-troubleshooting/dev-mode/' + } + target="_blank" + rel="noreferrer" + /> + ), + }, + } ), }, personal: { heading: __( diff --git a/client/onboarding/style.scss b/client/onboarding/style.scss index 68879da2039..1c118b5bf3c 100644 --- a/client/onboarding/style.scss +++ b/client/onboarding/style.scss @@ -12,7 +12,7 @@ body.wcpay-onboarding__body { top: 0; left: 0; height: 8px; - background-color: $gutenberg-blue; + background-color: var( --wp-admin-theme-color ); z-index: 11; transition: width 250ms; } @@ -106,7 +106,7 @@ body.wcpay-onboarding__body { padding: $gap-small $gap; } - .wcpay-banner-notice { + .personal-details-notice { margin: 0; } diff --git a/client/order/cancel-confirm-modal/index.js b/client/order/cancel-confirm-modal/index.js index 3ce6b3362d1..73acabfd3ba 100644 --- a/client/order/cancel-confirm-modal/index.js +++ b/client/order/cancel-confirm-modal/index.js @@ -60,7 +60,7 @@ const CancelConfirmationModal = ( { originalOrderStatus } ) => { howtoIssueRefunds: ( <a target="_blank" - href="https://woocommerce.com/document/woocommerce-payments/managing-money/#refunds" + href="https://woocommerce.com/document/woopayments/managing-money/#refunds" rel="noopener noreferrer" > { __( 'how to issue refunds', 'woocommerce-payments' ) } diff --git a/client/order/index.js b/client/order/index.js index 82c37fb75b9..35254d5a6da 100644 --- a/client/order/index.js +++ b/client/order/index.js @@ -5,6 +5,7 @@ import { dateI18n } from '@wordpress/date'; import ReactDOM from 'react-dom'; import { dispatch } from '@wordpress/data'; import moment from 'moment'; +import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies @@ -12,12 +13,15 @@ import moment from 'moment'; import { getConfig } from 'utils/order'; import RefundConfirmationModal from './refund-confirm-modal'; import CancelConfirmationModal from './cancel-confirm-modal'; -import BannerNotice from 'wcpay/components/banner-notice'; +import InlineNotice from 'components/inline-notice'; import { formatExplicitCurrency } from 'utils/currency'; import { reasons } from 'wcpay/disputes/strings'; import { getDetailsURL } from 'wcpay/components/details-link'; -import { disputeAwaitingResponseStatuses } from 'wcpay/disputes/filters/config'; -import { isInquiry } from 'wcpay/disputes/utils'; +import { + isAwaitingResponse, + isInquiry, + isUnderReview, +} from 'wcpay/disputes/utils'; import { useCharge } from 'wcpay/data'; import wcpayTracks from 'tracks'; import './style.scss'; @@ -130,102 +134,175 @@ jQuery( function ( $ ) { const DisputeNotice = ( { chargeId } ) => { const { data: charge } = useCharge( chargeId ); - if ( - ! charge?.dispute || - ! charge?.dispute?.evidence_details?.due_by || - // Only show the notice if the dispute is awaiting a response. - ! disputeAwaitingResponseStatuses.includes( charge?.dispute?.status ) - ) { + if ( ! charge?.dispute ) { return null; } const { dispute } = charge; - const now = moment(); - const dueBy = moment.unix( dispute.evidence_details?.due_by ); - const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); + let urgency = 'warning'; + let actions; - // If the dispute is due in the past, we don't want to show the notice. - if ( now.isAfter( dueBy ) ) { - return; - } + // Refunds are only allowed if the dispute is an inquiry or if it's won. + const isRefundable = + isInquiry( dispute ) || [ 'won' ].includes( dispute.status ); + const shouldDisableRefund = ! isRefundable; + let disableRefund = false; - const titleStrings = { - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - dispute_default: __( - // eslint-disable-next-line max-len - 'This order has been disputed in the amount of %1$s. The customer provided the following reason: %2$s. Please respond to this dispute before %3$s.', - 'woocommerce-payments' - ), - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - inquiry_default: __( - // eslint-disable-next-line max-len - 'The card network involved in this order has opened an inquiry into the transaction with the following reason: %2$s. Please respond to this inquiry before %3$s, just like you would for a formal dispute.', - 'woocommerce-payments' - ), - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - dispute_urgent: __( - 'Please resolve the dispute on this order for %1$s labeled "%2$s" by %3$s.', - 'woocommerce-payments' - ), - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - inquiry_urgent: __( - 'Please resolve the inquiry on this order for %1$s labeled "%2$s" by %3$s.', - 'woocommerce-payments' - ), - }; - const amountFormatted = formatExplicitCurrency( - dispute.amount, - dispute.currency - ); + let refundDisabledNotice = ''; + if ( shouldDisableRefund ) { + const refundButton = document.querySelector( 'button.refund-items' ); + if ( refundButton ) { + disableRefund = true; - let urgency = 'warning'; - let buttonLabel = __( 'Respond now', 'woocommerce-payments' ); - let suffix = ''; - - let titleText = isInquiry( dispute ) - ? titleStrings.inquiry_default - : titleStrings.dispute_default; - - // If the dispute is due within 7 days, use different wording. - if ( countdownDays < 7 ) { - titleText = isInquiry( dispute ) - ? titleStrings.inquiry_urgent - : titleStrings.dispute_urgent; - - suffix = sprintf( - // Translators: %s is the number of days left to respond to the dispute. - _n( - '(%s day left)', - '(%s days left)', - countdownDays, - 'woocommerce-payments' - ), - countdownDays - ); - } + // Disable the refund button. + refundButton.disabled = true; - const title = sprintf( - titleText, - amountFormatted, - reasons[ dispute.reason ].display, - dateI18n( 'M j, Y', dueBy.local().toISOString() ) - ); + const disputeDetailsLink = getDetailsURL( dispute.id, 'disputes' ); - // If the dispute is due within 72 hours, we want to highlight it as urgent/red. - if ( countdownDays < 3 ) { - urgency = 'error'; - } + let tooltipText = ''; + + if ( isAwaitingResponse( dispute.status ) ) { + refundDisabledNotice = __( + 'Refunds and order editing are disabled during disputes.', + 'woocommerce-payments' + ); + tooltipText = refundDisabledNotice; + } else if ( isUnderReview( dispute.status ) ) { + refundDisabledNotice = createInterpolateElement( + __( + // eslint-disable-next-line max-len + 'This order has an active payment dispute. Refunds and order editing are disabled during this time. <a>View details</a>', + 'woocommerce-payments' + ), + { + // eslint-disable-next-line jsx-a11y/anchor-has-content + a: <a href={ disputeDetailsLink } />, + } + ); + tooltipText = __( + 'Refunds and order editing are disabled during an active dispute.', + 'woocommerce-payments' + ); + } else if ( dispute.status === 'lost' ) { + refundDisabledNotice = createInterpolateElement( + __( + 'Refunds and order editing have been disabled as a result of a lost dispute. <a>View details</a>', + 'woocommerce-payments' + ), + { + // eslint-disable-next-line jsx-a11y/anchor-has-content + a: <a href={ disputeDetailsLink } />, + } + ); + tooltipText = __( + 'Refunds and order editing have been disabled as a result of a lost dispute.', + 'woocommerce-payments' + ); + } - if ( countdownDays < 1 ) { - buttonLabel = __( 'Respond today', 'woocommerce-payments' ); - suffix = __( '(Last day today)', 'woocommerce-payments' ); + // Change refund tooltip's text copy. + jQuery( refundButton ) + .parent() + .find( '.woocommerce-help-tip' ) + .attr( { + // jQuery.tipTip uses the title attribute to generate the tooltip. + title: tooltipText, + 'aria-label': tooltipText, + } ) + // Regenerate the tipTip tooltip. + .tipTip(); + } } - return ( - <BannerNotice - status={ urgency } - isDismissible={ false } - actions={ [ + + let showWarning = false; + let warningText = ''; + + if ( + dispute.evidence_details?.due_by && + // Only show the notice if the dispute is awaiting a response. + isAwaitingResponse( dispute.status ) + ) { + const now = moment(); + const dueBy = moment.unix( dispute.evidence_details?.due_by ); + const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); + + // If the dispute is due in the past, we don't want to show the notice. + if ( now.isBefore( dueBy ) ) { + showWarning = true; + + const titleStrings = { + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + dispute_default: __( + // eslint-disable-next-line max-len + 'This order has been disputed in the amount of %1$s. The customer provided the following reason: %2$s. Please respond to this dispute before %3$s.', + 'woocommerce-payments' + ), + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + inquiry_default: __( + // eslint-disable-next-line max-len + 'The card network involved in this order has opened an inquiry into the transaction with the following reason: %2$s. Please respond to this inquiry before %3$s, just like you would for a formal dispute.', + 'woocommerce-payments' + ), + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + dispute_urgent: __( + 'Please resolve the dispute on this order for %1$s labeled "%2$s" by %3$s.', + 'woocommerce-payments' + ), + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + inquiry_urgent: __( + 'Please resolve the inquiry on this order for %1$s labeled "%2$s" by %3$s.', + 'woocommerce-payments' + ), + }; + const amountFormatted = formatExplicitCurrency( + dispute.amount, + dispute.currency + ); + + let buttonLabel = __( 'Respond now', 'woocommerce-payments' ); + let suffix = ''; + + let titleText = isInquiry( dispute ) + ? titleStrings.inquiry_default + : titleStrings.dispute_default; + + // If the dispute is due within 7 days, use different wording. + if ( countdownDays < 7 ) { + titleText = isInquiry( dispute ) + ? titleStrings.inquiry_urgent + : titleStrings.dispute_urgent; + + suffix = sprintf( + // Translators: %s is the number of days left to respond to the dispute. + _n( + '(%s day left)', + '(%s days left)', + countdownDays, + 'woocommerce-payments' + ), + countdownDays + ); + } + + const title = sprintf( + titleText, + amountFormatted, + reasons[ dispute.reason ].display, + dateI18n( 'M j, Y', dueBy.local().toISOString() ) + ); + + // If the dispute is due within 72 hours, we want to highlight it as urgent/red. + if ( countdownDays < 3 ) { + urgency = 'error'; + } + + if ( countdownDays < 1 ) { + buttonLabel = __( 'Respond today', 'woocommerce-payments' ); + suffix = __( '(Last day today)', 'woocommerce-payments' ); + } + + actions = [ { label: buttonLabel, variant: 'secondary', @@ -243,11 +320,25 @@ const DisputeNotice = ( { chargeId } ) => { ); }, }, - ] } + ]; + + warningText = `${ title } ${ suffix }`; + } + } + + if ( ! showWarning && ! disableRefund ) { + return null; + } + + return ( + <InlineNotice + status={ urgency } + isDismissible={ false } + actions={ actions } > - <strong> - { title } { suffix } - </strong> - </BannerNotice> + { showWarning && <strong>{ warningText }</strong> } + + { disableRefund && <div>{ refundDisabledNotice }</div> } + </InlineNotice> ); }; diff --git a/client/order/style.scss b/client/order/style.scss index 665554de744..d67a64db9ad 100644 --- a/client/order/style.scss +++ b/client/order/style.scss @@ -1,4 +1,4 @@ -/* Overrides for dispute BannerNotice used in order edit screen. */ -#wcpay-order-payment-details-container .wcpay-banner-notice { +/* Overrides for dispute InlineNotice used in order edit screen. */ +#wcpay-order-payment-details-container .wcpay-inline-notice { margin: 24px 0 6px 0; } diff --git a/client/overview/index.js b/client/overview/index.js index dbdf1625f1d..13f13ed3c37 100644 --- a/client/overview/index.js +++ b/client/overview/index.js @@ -57,6 +57,7 @@ const OverviewPage = () => { overviewTasksVisibility, showUpdateDetailsTask, wpcomReconnectUrl, + enabledPaymentMethods, } = wcpaySettings; const { isLoading: settingsIsLoading, settings } = useSettings(); @@ -70,6 +71,7 @@ const OverviewPage = () => { showUpdateDetailsTask, wpcomReconnectUrl, activeDisputes, + enabledPaymentMethods, } ); const tasks = Array.isArray( tasksUnsorted ) && tasksUnsorted.sort( taskSort ); diff --git a/client/overview/task-list/strings.tsx b/client/overview/task-list/strings.tsx index a826f9ebed4..8ca397211ed 100644 --- a/client/overview/task-list/strings.tsx +++ b/client/overview/task-list/strings.tsx @@ -285,7 +285,7 @@ export default { ), }, // Strings needed for the progressive onboarding related tasks. - po_tasks: { + tasks: { no_payment_14_days: { title: __( 'Please add your bank details to keep selling', @@ -412,5 +412,15 @@ export default { }, action_label: __( 'Verify bank details', 'woocommerce-payments' ), }, + add_apms: { + title: __( + 'Add more ways for buyers to pay', + 'woocommerce-payments' + ), + description: __( + 'Enable payment methods that work seamlessly with WooPayments.', + 'woocommerce-payments' + ), + }, }, }; diff --git a/client/overview/task-list/tasks.tsx b/client/overview/task-list/tasks.tsx index a8435f75d72..eb1fbf3cfc6 100644 --- a/client/overview/task-list/tasks.tsx +++ b/client/overview/task-list/tasks.tsx @@ -17,6 +17,7 @@ import { getReconnectWpcomTask } from './tasks/reconnect-task'; import { getUpdateBusinessDetailsTask } from './tasks/update-business-details-task'; import { CachedDispute } from 'wcpay/types/disputes'; import { TaskItemProps } from './types'; +import { getAddApmsTask } from './tasks/add-apms-task'; // Requirements we don't want to show to the user because they are too generic/not useful. These refer to Stripe error codes. const requirementBlacklist = [ 'invalid_value_other' ]; @@ -25,12 +26,14 @@ interface TaskListProps { showUpdateDetailsTask: boolean; wpcomReconnectUrl: string; activeDisputes?: CachedDispute[]; + enabledPaymentMethods?: string[]; } export const getTasks = ( { showUpdateDetailsTask, wpcomReconnectUrl, activeDisputes = [], + enabledPaymentMethods = [], }: TaskListProps ): TaskItemProps[] => { const { status, @@ -63,27 +66,26 @@ export const getTasks = ( { }; const isPoEnabled = progressiveOnboarding?.isEnabled; + const isPoComplete = progressiveOnboarding?.isComplete; + const isPoInProgress = isPoEnabled && ! isPoComplete; const errorMessages = getErrorMessagesFromRequirements(); + const isUpdateDetailsTaskVisible = + showUpdateDetailsTask && + ( ! isPoEnabled || ( isPoEnabled && ! detailsSubmitted ) ); + const isDisputeTaskVisible = !! activeDisputes && // Only show the dispute task if there are disputes due within 7 days. 0 < getDisputesDueWithinDays( activeDisputes, 7 ).length; + const isAddApmsTaskVisible = + enabledPaymentMethods?.length === 1 && + detailsSubmitted && + ! isPoInProgress; + return [ - showUpdateDetailsTask && - ! isPoEnabled && - getUpdateBusinessDetailsTask( - errorMessages, - status ?? '', - accountLink, - Number( currentDeadline ) ?? null, - pastDue ?? false, - detailsSubmitted ?? true - ), - showUpdateDetailsTask && - isPoEnabled && - ! detailsSubmitted && + isUpdateDetailsTaskVisible && getUpdateBusinessDetailsTask( errorMessages, status ?? '', @@ -95,6 +97,7 @@ export const getTasks = ( { wpcomReconnectUrl && getReconnectWpcomTask( wpcomReconnectUrl ), isDisputeTaskVisible && getDisputeResolutionTask( activeDisputes ), isPoEnabled && detailsSubmitted && getVerifyBankAccountTask(), + isAddApmsTaskVisible && getAddApmsTask(), ].filter( Boolean ); }; diff --git a/client/overview/task-list/tasks/add-apms-task.tsx b/client/overview/task-list/tasks/add-apms-task.tsx new file mode 100644 index 00000000000..29eb599339b --- /dev/null +++ b/client/overview/task-list/tasks/add-apms-task.tsx @@ -0,0 +1,32 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import type { TaskItemProps } from '../types'; +import strings from '../strings'; +import { getAdminUrl } from 'wcpay/utils'; + +export const getAddApmsTask = (): TaskItemProps | null => { + const handleClick = () => { + window.location.href = getAdminUrl( { + page: 'wc-admin', + path: '/payments/additional-payment-methods', + } ); + }; + + return { + key: 'add-apms', + level: 3, + content: '', + title: strings.tasks.add_apms.title, + additionalInfo: strings.tasks.add_apms.description, + completed: false, + onClick: handleClick, + action: handleClick, + expandable: false, + showActionButton: false, + }; +}; diff --git a/client/overview/task-list/tasks/po-task.tsx b/client/overview/task-list/tasks/po-task.tsx index 973a6c0c31a..79aa738415e 100644 --- a/client/overview/task-list/tasks/po-task.tsx +++ b/client/overview/task-list/tasks/po-task.tsx @@ -58,27 +58,27 @@ export const getVerifyBankAccountTask = (): any => { // When account is created less than 14 days ago, we also show a notice but it's just info. if ( 14 > daysFromAccountCreation ) { - title = strings.po_tasks.after_payment.title; + title = strings.tasks.after_payment.title; level = 3; - description = strings.po_tasks.after_payment.description( + description = strings.tasks.after_payment.description( verifyDetailsDueDate ); - actionLabelText = strings.po_tasks.after_payment.action_label; + actionLabelText = strings.tasks.after_payment.action_label; } if ( 14 <= daysFromAccountCreation ) { - title = strings.po_tasks.no_payment_14_days.title; + title = strings.tasks.no_payment_14_days.title; level = 2; - description = strings.po_tasks.no_payment_14_days.description( + description = strings.tasks.no_payment_14_days.description( verifyDetailsDueDate ); - actionLabelText = strings.po_tasks.no_payment_14_days.action_label; + actionLabelText = strings.tasks.no_payment_14_days.action_label; } if ( 30 <= daysFromAccountCreation ) { - title = strings.po_tasks.no_payment_30_days.title; + title = strings.tasks.no_payment_30_days.title; level = 1; - description = strings.po_tasks.no_payment_30_days.description; - actionLabelText = strings.po_tasks.no_payment_30_days.action_label; + description = strings.tasks.no_payment_30_days.description; + actionLabelText = strings.tasks.no_payment_30_days.action_label; } } else { const tpvInUsd = tpv / 100; @@ -87,39 +87,39 @@ export const getVerifyBankAccountTask = (): any => { .format( 'MMMM D, YYYY' ); const daysFromFirstPayment = moment().diff( firstPaymentDate, 'days' ); - title = strings.po_tasks.after_payment.title; + title = strings.tasks.after_payment.title; level = 3; - description = strings.po_tasks.after_payment.description( + description = strings.tasks.after_payment.description( verifyDetailsDueDate ); - actionLabelText = strings.po_tasks.after_payment.action_label; + actionLabelText = strings.tasks.after_payment.action_label; // Balance is rising. if ( tpvLimit * 0.2 <= tpvInUsd || 7 <= daysFromFirstPayment ) { - title = strings.po_tasks.balance_rising.title; + title = strings.tasks.balance_rising.title; level = 2; - description = strings.po_tasks.balance_rising.description( + description = strings.tasks.balance_rising.description( verifyDetailsDueDate ); - actionLabelText = strings.po_tasks.balance_rising.action_label; + actionLabelText = strings.tasks.balance_rising.action_label; } // Near threshold. if ( tpvLimit * 0.6 <= tpvInUsd || 21 <= daysFromFirstPayment ) { - title = strings.po_tasks.near_threshold.title; + title = strings.tasks.near_threshold.title; level = 1; - description = strings.po_tasks.near_threshold.description( + description = strings.tasks.near_threshold.description( verifyDetailsDueDate ); - actionLabelText = strings.po_tasks.near_threshold.action_label; + actionLabelText = strings.tasks.near_threshold.action_label; } // Threshold reached. if ( tpvLimit <= tpvInUsd || 30 <= daysFromFirstPayment ) { - title = strings.po_tasks.threshold_reached.title; + title = strings.tasks.threshold_reached.title; level = 1; - description = strings.po_tasks.threshold_reached.description( + description = strings.tasks.threshold_reached.description( verifyDetailsDueDate ); - actionLabelText = strings.po_tasks.threshold_reached.action_label; + actionLabelText = strings.tasks.threshold_reached.action_label; } } diff --git a/client/overview/task-list/tasks/update-business-details-task.tsx b/client/overview/task-list/tasks/update-business-details-task.tsx index 84574d3433f..5f6542c67ff 100644 --- a/client/overview/task-list/tasks/update-business-details-task.tsx +++ b/client/overview/task-list/tasks/update-business-details-task.tsx @@ -123,7 +123,7 @@ export const getUpdateBusinessDetailsTask = ( title: ! detailsSubmitted ? sprintf( /* translators: %s: WooPayments */ - __( 'Set up %s', 'woocommerce-payments' ), + __( 'Finish setting up %s', 'woocommerce-payments' ), 'WooPayments' ) : sprintf( diff --git a/client/overview/task-list/test/tasks.js b/client/overview/task-list/test/tasks.js index 3d54ba80834..44b5de4e9f7 100644 --- a/client/overview/task-list/test/tasks.js +++ b/client/overview/task-list/test/tasks.js @@ -173,7 +173,7 @@ describe( 'getTasks()', () => { expect.objectContaining( { key: 'complete-setup', completed: false, - title: 'Set up WooPayments', + title: 'Finish setting up WooPayments', actionLabel: 'Finish setup', } ), ] ) diff --git a/client/payment-details/charge-details/index.tsx b/client/payment-details/charge-details/index.tsx index 25c5794fc9d..9d4ad2a141b 100644 --- a/client/payment-details/charge-details/index.tsx +++ b/client/payment-details/charge-details/index.tsx @@ -2,6 +2,7 @@ * External dependencies */ import React, { useEffect } from 'react'; +import { getHistory } from '@woocommerce/navigation'; /** * Internal dependencies @@ -54,7 +55,7 @@ const PaymentChargeDetails: React.FC< PaymentChargeDetailsProps > = ( { id: data.payment_intent, } ); - window.location.href = url; + getHistory().replace( url ); } }, [ data, isChargeId ] ); diff --git a/client/payment-details/dispute-details/dispute-notice.tsx b/client/payment-details/dispute-details/dispute-notice.tsx new file mode 100644 index 00000000000..44c3286704b --- /dev/null +++ b/client/payment-details/dispute-details/dispute-notice.tsx @@ -0,0 +1,71 @@ +/** @format **/ + +/** + * External dependencies + */ +import React from 'react'; +import { __, sprintf } from '@wordpress/i18n'; +import { createInterpolateElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import './style.scss'; +import InlineNotice from 'components/inline-notice'; +import { reasons } from 'wcpay/disputes/strings'; +import { Dispute } from 'wcpay/types/disputes'; +import { isInquiry } from 'wcpay/disputes/utils'; + +interface DisputeNoticeProps { + dispute: Dispute; + urgent: boolean; +} + +const DisputeNotice: React.FC< DisputeNoticeProps > = ( { + dispute, + urgent, +} ) => { + const clientClaim = + reasons[ dispute.reason ]?.claim ?? + __( + 'The cardholder claims this is an unrecognized charge.', + 'woocommerce-payments' + ); + + const noticeText = isInquiry( dispute ) + ? /* translators: <a> link to dispute documentation. %s is the clients claim for the dispute, eg "The cardholder claims this is an unrecognized charge." */ + __( + // eslint-disable-next-line max-len + '<strong>%s</strong> You can challenge their claim if you believe it’s invalid. Not responding will result in an automatic loss. <a>Learn more</a>', + 'woocommerce-payments' + ) + : /* translators: <a> link to dispute documentation. %s is the clients claim for the dispute, eg "The cardholder claims this is an unrecognized charge." */ + __( + // eslint-disable-next-line max-len + '<strong>%s</strong> Challenge the dispute if you believe the claim is invalid, or accept to forfeit the funds and pay the dispute fee. Non-response will result in an automatic loss. <a>Learn more about responding to disputes</a>', + 'woocommerce-payments' + ); + + return ( + <InlineNotice + icon + status={ urgent ? 'error' : 'warning' } + className="dispute-notice" + isDismissible={ false } + > + { createInterpolateElement( sprintf( noticeText, clientClaim ), { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + <a + target="_blank" + rel="noopener noreferrer" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#section-3" + /> + ), + strong: <strong />, + } ) } + </InlineNotice> + ); +}; + +export default DisputeNotice; diff --git a/client/payment-details/dispute-details/dispute-summary-row.tsx b/client/payment-details/dispute-details/dispute-summary-row.tsx new file mode 100644 index 00000000000..160abaeb000 --- /dev/null +++ b/client/payment-details/dispute-details/dispute-summary-row.tsx @@ -0,0 +1,131 @@ +/** @format **/ + +/** + * External dependencies + */ +import React from 'react'; +import moment from 'moment'; +import HelpOutlineIcon from 'gridicons/dist/help-outline'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { dateI18n } from '@wordpress/date'; +import classNames from 'classnames'; + +/** + * Internal dependencies + */ +import type { Dispute } from 'wcpay/types/disputes'; +import { HorizontalList } from 'wcpay/components/horizontal-list'; +import { formatCurrency } from 'wcpay/utils/currency'; +import { reasons } from 'wcpay/disputes/strings'; +import { formatStringValue } from 'wcpay/utils'; +import { ClickTooltip } from 'wcpay/components/tooltip'; +import Paragraphs from 'wcpay/components/paragraphs'; + +interface Props { + dispute: Dispute; + daysRemaining: number; +} + +const DisputeSummaryRow: React.FC< Props > = ( { dispute, daysRemaining } ) => { + const respondByDate = dispute.evidence_details?.due_by + ? dateI18n( + 'M j, Y, g:ia', + moment( dispute.evidence_details?.due_by * 1000 ).toISOString() + ) + : '–'; + + const disputeReason = formatStringValue( + reasons[ dispute.reason ]?.display || dispute.reason + ); + const disputeReasonSummary = reasons[ dispute.reason ]?.summary || []; + + const columns = [ + { + title: __( 'Dispute Amount', 'woocommerce-payments' ), + content: formatCurrency( dispute.amount, dispute.currency ), + }, + { + title: __( 'Disputed On', 'woocommerce-payments' ), + content: dispute.created + ? dateI18n( + 'M j, Y, g:ia', + moment( dispute.created * 1000 ).toISOString() + ) + : '–', + }, + { + title: __( 'Reason', 'woocommerce-payments' ), + content: ( + <> + { disputeReason } + { disputeReasonSummary.length > 0 && ( + <ClickTooltip + buttonIcon={ <HelpOutlineIcon /> } + buttonLabel={ __( + 'Learn more', + 'woocommerce-payments' + ) } + content={ + <div className="dispute-reason-tooltip"> + <p>{ disputeReason }</p> + <Paragraphs> + { disputeReasonSummary } + </Paragraphs> + <p> + <a + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/" + target="_blank" + rel="noopener noreferrer" + > + { __( + 'Learn more', + 'woocommerce-payments' + ) } + </a> + </p> + </div> + } + /> + ) } + </> + ), + }, + { + title: __( 'Respond By', 'woocommerce-payments' ), + content: ( + <span className="dispute-summary-row__response-date"> + { respondByDate } + <span + className={ classNames( { + 'dispute-summary-row__response-date--urgent': + daysRemaining < 3, + 'dispute-summary-row__response-date--warning': + daysRemaining < 7 && daysRemaining > 2, + } ) } + > + { daysRemaining === 0 + ? __( '(Last day today)', 'woocommerce-payments' ) + : sprintf( + // Translators: %s is the number of days left to respond to the dispute. + _n( + '(%s day left to respond)', + '(%s days left to respond)', + daysRemaining, + 'woocommerce-payments' + ), + daysRemaining + ) } + </span> + </span> + ), + }, + ]; + + return ( + <div className="dispute-summary-row"> + <HorizontalList items={ columns } /> + </div> + ); +}; + +export default DisputeSummaryRow; diff --git a/client/payment-details/dispute-details/evidence-list.tsx b/client/payment-details/dispute-details/evidence-list.tsx new file mode 100644 index 00000000000..e6610b74206 --- /dev/null +++ b/client/payment-details/dispute-details/evidence-list.tsx @@ -0,0 +1,144 @@ +/** @format **/ + +/** + * External dependencies + */ +import React from 'react'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import { useDispatch } from '@wordpress/data'; +import { Button, PanelBody } from '@wordpress/components'; +import { Icon, page } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import type { IssuerEvidence } from 'wcpay/types/disputes'; +import { useFiles } from 'wcpay/data'; +import Loadable from 'wcpay/components/loadable'; +import { NAMESPACE } from 'wcpay/data/constants'; +import { FileDownload } from 'wcpay/data/files/types'; + +const TextEvidence: React.FC< { + evidence: string; +} > = ( { evidence } ) => { + const onClick = () => { + const link = document.createElement( 'a' ); + link.href = URL.createObjectURL( + new Blob( [ evidence ], { type: 'text/plain' } ) + ); + link.download = 'evidence.txt'; + link.click(); + }; + + return ( + <Button + variant="secondary" + onClick={ onClick } + isSmall={ true } + icon={ <Icon icon={ page } /> } + > + { + /* translators: Default filename for issuer evidence on a dispute */ + __( 'Evidence.txt', 'woocommerce-payments' ) + } + </Button> + ); +}; + +const FileEvidence: React.FC< { + fileId: string; +} > = ( { fileId } ) => { + const { file, isLoading } = useFiles( fileId ); + const { createNotice } = useDispatch( 'core/notices' ); + const [ isDownloading, setIsDownloading ] = React.useState( false ); + + const onClick = async () => { + if ( ! file || ! file.id || isDownloading ) { + return; + } + try { + setIsDownloading( true ); + const downloadRequest = await apiFetch< FileDownload >( { + path: `${ NAMESPACE }/file/${ encodeURI( file.id ) }/content`, + method: 'GET', + } ); + + const link = document.createElement( 'a' ); + link.href = + 'data:application/octect-stream;base64,' + + downloadRequest.file_content; + link.download = file.filename; + link.click(); + } catch ( exception ) { + createNotice( + 'error', + __( 'Error downloading file', 'woocommerce-payments' ) + ); + } + setIsDownloading( false ); + }; + + return ( + <Loadable + isLoading={ isLoading } + placeholder={ __( 'Loading', 'woocommerce-payments' ) } + > + { file && file.id ? ( + <Button + variant="secondary" + isBusy={ isDownloading } + disabled={ isDownloading } + isSmall={ true } + icon={ <Icon icon={ page } /> } + onClick={ onClick } + > + { file?.title || file.filename } + </Button> + ) : ( + <></> + ) } + </Loadable> + ); +}; + +interface Props { + issuerEvidence: IssuerEvidence | null; +} + +const IssuerEvidenceList: React.FC< Props > = ( { issuerEvidence } ) => { + if ( + ! issuerEvidence || + ! issuerEvidence.file_evidence.length || + ! issuerEvidence.text_evidence + ) { + return <></>; + } + + return ( + <PanelBody + className="dispute-evidence" + title={ __( 'Issuer evidence', 'woocommerce' ) } + initialOpen={ false } + > + <ul className="dispute-evidence__list"> + { issuerEvidence.text_evidence && ( + <li className="dispute-evidence__list-item"> + <TextEvidence + evidence={ issuerEvidence.text_evidence } + /> + </li> + ) } + { issuerEvidence.file_evidence.map( + ( fileId: string, i: any ) => ( + <li className="dispute-evidence__list-item" key={ i }> + <FileEvidence fileId={ fileId } /> + </li> + ) + ) } + </ul> + </PanelBody> + ); +}; + +export default IssuerEvidenceList; diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx index 9b710d7050d..bb41511d293 100644 --- a/client/payment-details/dispute-details/index.tsx +++ b/client/payment-details/dispute-details/index.tsx @@ -4,11 +4,20 @@ * External dependencies */ import React from 'react'; +import moment from 'moment'; +import { __ } from '@wordpress/i18n'; +import { Card, CardBody } from '@wordpress/components'; +import { edit } from '@wordpress/icons'; + /** * Internal dependencies */ import type { Dispute } from 'wcpay/types/disputes'; -import { Card, CardBody } from '@wordpress/components'; +import { isAwaitingResponse } from 'wcpay/disputes/utils'; +import DisputeNotice from './dispute-notice'; +import IssuerEvidenceList from './evidence-list'; +import DisputeSummaryRow from './dispute-summary-row'; +import InlineNotice from 'components/inline-notice'; import './style.scss'; interface DisputeDetailsProps { @@ -16,11 +25,42 @@ interface DisputeDetailsProps { } const DisputeDetails: React.FC< DisputeDetailsProps > = ( { dispute } ) => { + const now = moment(); + const dueBy = moment.unix( dispute.evidence_details?.due_by ?? 0 ); + const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); + const hasStagedEvidence = dispute.evidence_details?.has_evidence; + return ( <div className="transaction-details-dispute-details-wrapper"> <Card> - <CardBody> - <div></div> + <CardBody className="transaction-details-dispute-details-body"> + { isAwaitingResponse( dispute.status ) && + countdownDays >= 0 && ( + <> + <DisputeNotice + dispute={ dispute } + urgent={ countdownDays <= 2 } + /> + { hasStagedEvidence && ( + <InlineNotice + icon={ edit } + isDismissible={ false } + > + { __( + `You initiated a challenge to this dispute. Click 'Continue with challenge' to proceed with your draft response.`, + 'woocommerce-payments' + ) } + </InlineNotice> + ) } + <DisputeSummaryRow + dispute={ dispute } + daysRemaining={ countdownDays } + /> + <IssuerEvidenceList + issuerEvidence={ dispute.issuer_evidence } + /> + </> + ) } </CardBody> </Card> </div> diff --git a/client/payment-details/dispute-details/style.scss b/client/payment-details/dispute-details/style.scss index 8a4ec037835..628ab098b39 100644 --- a/client/payment-details/dispute-details/style.scss +++ b/client/payment-details/dispute-details/style.scss @@ -4,4 +4,84 @@ padding-left: 24px; padding-right: 24px; padding-bottom: 5px; + + .transaction-details-dispute-details-body { + padding: $grid-unit-20; + + .wcpay-inline-notice.components-notice { + margin: 0 0 10px 0; + + &:last-child { + margin-bottom: 24px; + } + } + + .dispute-summary-row { + margin: 24px 0; + + &__response-date { + display: flex; + align-items: center; + gap: var( --grid-unit-05, 4px ); + flex-wrap: wrap; + &--warning { + color: $wp-yellow-30; + font-weight: 700; + } + &--urgent { + font-weight: 700; + color: $alert-red; + } + } + } + } +} +.dispute-reason-tooltip { + p { + &:first-child { + font-weight: bold; + } + &:last-child { + margin-bottom: 0; + } + margin: 0; + margin-bottom: 8px; + } +} + +.dispute-evidence { + // Override WordPress core PanelBody boxy styles. Ours is more inline content. + &.components-panel__body { + border: none; + } + // Override WordPress core PanelBody padding so fits snug in our container. + &.components-panel__body.is-opened { + padding-bottom: 0; + } + // Override WordPress core PanelBody title to align with other nearby headings. + .components-panel__body-title button { + // Copy of WooCommerce core list table header style. + text-transform: uppercase; + color: #757575; + font-size: 11px; + font-weight: 600; + } + // Override WordPress core PanelBody button/title – slim padding consistent with surrounding components. + .components-panel__body-toggle.components-button { + padding: 10px; + } + // Override WordPress core PanelBody focus/highlighting. + .components-panel__body-toggle.components-button:focus { + box-shadow: none; + outline: 0; + } + &__list { + list-style: none; + margin: 8px 0 0; + } + &__list-item { + display: inline-block; + margin-right: 12px; + margin-bottom: 0; + } } diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx new file mode 100644 index 00000000000..9787972fd17 --- /dev/null +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -0,0 +1,203 @@ +/** @format */ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +/** + * Internal dependencies + */ +import type { Dispute } from 'wcpay/types/disputes'; +import type { Charge } from 'wcpay/types/charges'; +import DisputeDetails from '..'; + +declare const global: { + wcSettings: { + locale: { + siteLocale: string; + }; + }; + wcpaySettings: { + isSubscriptionsActive: boolean; + zeroDecimalCurrencies: string[]; + currencyData: Record< string, any >; + connect: { + country: string; + }; + featureFlags: { + isAuthAndCaptureEnabled: boolean; + }; + }; +}; + +global.wcpaySettings = { + isSubscriptionsActive: false, + zeroDecimalCurrencies: [], + connect: { + country: 'US', + }, + featureFlags: { + isAuthAndCaptureEnabled: true, + }, + currencyData: { + US: { + code: 'USD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + }, +}; + +interface ChargeWithDisputeRequired extends Charge { + dispute: Dispute; +} + +const getBaseCharge = (): ChargeWithDisputeRequired => + ( { + id: 'ch_38jdHA39KKA', + /* Stripe data comes in seconds, instead of the default Date milliseconds */ + created: Date.parse( 'Sep 19, 2019, 5:24 pm' ) / 1000, + amount: 2000, + amount_refunded: 0, + application_fee_amount: 70, + disputed: true, + dispute: { + id: 'dp_1', + amount: 6800, + charge: 'ch_38jdHA39KKA', + order: null, + balance_transactions: [ + { + amount: -2000, + currency: 'usd', + fee: 1500, + }, + ], + created: 1693453017, + currency: 'usd', + evidence: { + billing_address: '123 test address', + customer_email_address: 'test@email.com', + customer_name: 'Test customer', + shipping_address: '123 test address', + }, + issuer_evidence: null, + evidence_details: { + due_by: 1694303999, + has_evidence: false, + past_due: false, + submission_count: 0, + }, + // issuer_evidence: null, + metadata: [], + payment_intent: 'pi_1', + reason: 'fraudulent', + status: 'needs_response', + } as Dispute, + currency: 'usd', + type: 'charge', + status: 'succeeded', + paid: true, + captured: true, + balance_transaction: { + amount: 2000, + currency: 'usd', + fee: 70, + }, + refunds: { + data: [], + }, + order: { + number: 45981, + url: 'https://somerandomorderurl.com/?edit_order=45981', + }, + billing_details: { + name: 'Customer name', + }, + payment_method_details: { + card: { + brand: 'visa', + last4: '4242', + }, + type: 'card', + }, + outcome: { + risk_level: 'normal', + }, + } as any ); + +describe( 'DisputeDetails', () => { + beforeEach( () => { + // mock Date.now that moment library uses to get current date for testing purposes + Date.now = jest.fn( () => + new Date( '2023-09-08T12:33:37.000Z' ).getTime() + ); + } ); + afterEach( () => { + // roll it back + Date.now = () => new Date().getTime(); + } ); + test( 'correctly renders dispute details', () => { + const charge = getBaseCharge(); + render( <DisputeDetails dispute={ charge.dispute } /> ); + + // Expect this warning to be logged to the console + expect( console ).toHaveWarnedWith( + 'List with items prop is deprecated is deprecated and will be removed in version 9.0.0. Note: See ExperimentalList / ExperimentalListItem for the new API that will replace this component in future versions.' + ); + + // Dispute Notice + screen.getByText( + /The cardholder claims this is an unauthorized transaction/, + { ignore: '.a11y-speak-region' } + ); + + // Don't render the staged evidence message + expect( + screen.queryByText( + /You initiated a dispute a challenge to this dispute/, + { ignore: '.a11y-speak-region' } + ) + ).toBeNull(); + + // Dispute Summary Row + expect( + screen.getByText( /Dispute Amount/i ).nextSibling + ).toHaveTextContent( /\$68.00/ ); + expect( + screen.getByText( /Disputed On/i ).nextSibling + ).toHaveTextContent( /Aug 30, 2023/ ); + expect( screen.getByText( /Reason/i ).nextSibling ).toHaveTextContent( + /Transaction unauthorized/ + ); + expect( + screen.getByText( /Respond By/i ).nextSibling + ).toHaveTextContent( /Sep 9, 2023/ ); + } ); + + test( 'correctly renders dispute details for a dispute with staged evidence', () => { + const charge = getBaseCharge(); + charge.dispute.evidence_details = { + has_evidence: true, + due_by: 1694303999, + past_due: false, + submission_count: 0, + }; + + render( <DisputeDetails dispute={ charge.dispute } /> ); + + screen.getByText( + /The cardholder claims this is an unauthorized transaction/, + { ignore: '.a11y-speak-region' } + ); + + // Render the staged evidence message + screen.getByText( /You initiated a challenge to this dispute/, { + ignore: '.a11y-speak-region', + } ); + } ); +} ); diff --git a/client/payment-details/summary/index.tsx b/client/payment-details/summary/index.tsx index 8ed8e612bd8..e75f69f2caf 100644 --- a/client/payment-details/summary/index.tsx +++ b/client/payment-details/summary/index.tsx @@ -394,7 +394,7 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { a: ( // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-no-target-blank <a - href="https://woocommerce.com/document/woocommerce-payments/settings-guide/authorize-and-capture/#capturing-authorized-orders" + href="https://woocommerce.com/document/woopayments/settings-guide/authorize-and-capture/#capturing-authorized-orders" target="_blank" rel="noreferer" /> diff --git a/client/payment-details/summary/test/__snapshots__/index.tsx.snap b/client/payment-details/summary/test/__snapshots__/index.tsx.snap index 28729a20fa3..9dd86ba7c81 100644 --- a/client/payment-details/summary/test/__snapshots__/index.tsx.snap +++ b/client/payment-details/summary/test/__snapshots__/index.tsx.snap @@ -256,7 +256,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders ca > You must <a - href="https://woocommerce.com/document/woocommerce-payments/settings-guide/authorize-and-capture/#capturing-authorized-orders" + href="https://woocommerce.com/document/woopayments/settings-guide/authorize-and-capture/#capturing-authorized-orders" rel="noreferer" target="_blank" > @@ -573,7 +573,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders th > You must <a - href="https://woocommerce.com/document/woocommerce-payments/settings-guide/authorize-and-capture/#capturing-authorized-orders" + href="https://woocommerce.com/document/woopayments/settings-guide/authorize-and-capture/#capturing-authorized-orders" rel="noreferer" target="_blank" > diff --git a/client/payment-details/test/index.test.tsx b/client/payment-details/test/index.test.tsx index d0f374e8d71..ce879aabf51 100644 --- a/client/payment-details/test/index.test.tsx +++ b/client/payment-details/test/index.test.tsx @@ -40,6 +40,7 @@ jest.mock( '@wordpress/data', () => ( { useSelect: jest.fn(), } ) ); +const mockHistoryReplace = jest.fn(); jest.mock( '@woocommerce/navigation', () => ( { getQuery: () => { return { @@ -47,6 +48,9 @@ jest.mock( '@woocommerce/navigation', () => ( { type_is: '', }; }, + getHistory: () => ( { + replace: mockHistoryReplace, + } ), addHistoryListener: jest.fn(), } ) ); @@ -151,6 +155,7 @@ describe( 'Payment details page', () => { Object.defineProperty( window, 'location', { value: { href: 'http://example.com' }, } ); + mockHistoryReplace.mockReset(); } ); afterAll( () => { @@ -182,14 +187,12 @@ describe( 'Payment details page', () => { it( 'should redirect from ch_mock to pi_mock', () => { render( <PaymentDetailsPage query={ chargeQuery } /> ); - expect( window.location.href ).toEqual( redirectUrl ); + expect( mockHistoryReplace ).toHaveBeenCalledWith( redirectUrl ); } ); it( 'should not redirect with a payment intent ID as query param', () => { - const { href } = window.location; - render( <PaymentDetailsPage query={ paymentIntentQuery } /> ); - expect( window.location.href ).toEqual( href ); + expect( mockHistoryReplace ).not.toHaveBeenCalled(); } ); } ); diff --git a/client/payment-gateways/disable-confirmation-modal.js b/client/payment-gateways/disable-confirmation-modal.js index 23417958ed9..d5c19084041 100644 --- a/client/payment-gateways/disable-confirmation-modal.js +++ b/client/payment-gateways/disable-confirmation-modal.js @@ -113,7 +113,7 @@ const DisableConfirmationModal = ( { onClose, onConfirm } ) => { strong: <strong />, wooCommercePaymentsLink: ( // eslint-disable-next-line jsx-a11y/anchor-has-content - <a href="https://woocommerce.com/document/woocommerce-payments/" /> + <a href="https://woocommerce.com/document/woopayments/" /> ), contactSupportLink: ( // eslint-disable-next-line jsx-a11y/anchor-has-content diff --git a/client/payment-methods/index.js b/client/payment-methods/index.js index fd738363848..d47b2ddb9e1 100644 --- a/client/payment-methods/index.js +++ b/client/payment-methods/index.js @@ -8,7 +8,6 @@ import { __ } from '@wordpress/i18n'; import { Button, Card, - CardDivider, CardHeader, DropdownMenu, ExternalLink, @@ -48,6 +47,8 @@ import ConfirmPaymentMethodActivationModal from './activation-modal'; import ConfirmPaymentMethodDeleteModal from './delete-modal'; import { getAdminUrl } from 'wcpay/utils'; import { getPaymentMethodDescription } from 'wcpay/utils/payment-methods'; +import InlineNotice from 'wcpay/components/inline-notice'; +import interpolateComponents from '@automattic/interpolate-components'; const PaymentMethodsDropdownMenu = ( { setOpenModal } ) => { return ( @@ -82,7 +83,6 @@ const UpeSetupBanner = () => { return ( <> - <CardDivider /> <CardBody className={ classNames( 'payment-methods__express-checkouts', { 'background-local-payment-methods': ! wcpaySettings.isBnplAffirmAfterpayEnabled, @@ -90,14 +90,14 @@ const UpeSetupBanner = () => { > <h3> { __( - 'Boost your sales by accepting additional payment methods', + 'Enable the new WooPayments checkout experience, which will become the default on November 1, 2023', 'woocommerce-payments' ) } </h3> <p> { __( /* eslint-disable-next-line max-len */ - 'Get access to additional payment methods and an improved checkout experience.', + 'This will improve the checkout experience and boost sales with access to additional payment methods, which you’ll be able to manage from here in settings.', 'woocommerce-payments' ) } </p> @@ -106,12 +106,12 @@ const UpeSetupBanner = () => { <span className="payment-methods__express-checkouts-get-started"> <Button isSecondary onClick={ handleEnableUpeClick }> { __( - 'Enable in your store', + 'Enable payment methods', 'woocommerce-payments' ) } </Button> </span> - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/payment-methods/additional-payment-methods/"> + <ExternalLink href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/"> { __( 'Learn more', 'woocommerce-payments' ) } </ExternalLink> </div> @@ -278,6 +278,30 @@ const PaymentMethods = () => { </CardHeader> ) } + { isUpeEnabled && upeType === 'legacy' && ( + <CardHeader className="payment-methods__header"> + <InlineNotice + icon + status="warning" + isDismissible={ false } + > + { interpolateComponents( { + mixedString: __( + 'The new WooPayments checkout experience will become the default on October 11, 2023.' + + ' {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'woocommerce-payments' + ), + components: { + learnMoreLink: ( + // eslint-disable-next-line max-len + <ExternalLink href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/#popular-payment-methods" /> + ), + }, + } ) } + </InlineNotice> + </CardHeader> + ) } + <CardBody size={ null }> <PaymentMethodsList className="payment-methods__available-methods"> { availableMethods.map( @@ -341,10 +365,21 @@ const PaymentMethods = () => { ) } </PaymentMethodsList> </CardBody> - { isUpeSettingsPreviewEnabled && ! isUpeEnabled && ( - <UpeSetupBanner /> - ) } </Card> + + { isUpeSettingsPreviewEnabled && ! isUpeEnabled && ( + <> + <br /> + <Card + className={ classNames( 'payment-methods', { + 'is-loading': status === 'pending', + } ) } + > + <UpeSetupBanner /> + </Card> + </> + ) } + { activationModalParams && ( <ConfirmPaymentMethodActivationModal onClose={ () => { diff --git a/client/payment-methods/test/index.js b/client/payment-methods/test/index.js index 2830f9d3c55..5d2ebf69ff9 100644 --- a/client/payment-methods/test/index.js +++ b/client/payment-methods/test/index.js @@ -227,7 +227,7 @@ describe( 'PaymentMethods', () => { ); const enableWooCommercePaymentText = screen.getByText( - 'Boost your sales by accepting additional payment methods' + 'Enable the new WooPayments checkout experience, which will become the default on November 1, 2023' ); expect( enableWooCommercePaymentText ).toBeInTheDocument(); @@ -342,7 +342,7 @@ describe( 'PaymentMethods', () => { ); const enableWooCommercePaymentText = screen.getByText( - 'Boost your sales by accepting additional payment methods' + 'Enable the new WooPayments checkout experience, which will become the default on November 1, 2023' ); expect( enableWooCommercePaymentText.parentElement ).not.toHaveClass( @@ -371,7 +371,7 @@ describe( 'PaymentMethods', () => { ); const enableWooCommercePaymentText = screen.getByText( - 'Boost your sales by accepting additional payment methods' + 'Enable the new WooPayments checkout experience, which will become the default on November 1, 2023' ); expect( enableWooCommercePaymentText.parentElement ).toHaveClass( @@ -404,7 +404,7 @@ describe( 'PaymentMethods', () => { ); const enableWooCommercePaymentText = screen.queryByText( - 'Boost your sales by accepting additional payment methods' + 'Enable the new WooPayments checkout experience, which will become the default on November 1, 2023' ); expect( enableWooCommercePaymentText ).toBeNull(); @@ -444,7 +444,7 @@ describe( 'PaymentMethods', () => { ).not.toBeInTheDocument(); } ); - test( 'clicking "Enable in your store" in express payments enable UPE and redirects', async () => { + test( 'clicking "Enable payment methods" in express payments enable UPE and redirects', async () => { Object.defineProperty( window, 'location', { value: { href: 'example.com/', @@ -471,7 +471,7 @@ describe( 'PaymentMethods', () => { ); const enableInYourStoreButton = screen.queryByRole( 'button', { - name: 'Enable in your store', + name: 'Enable payment methods', } ); expect( enableInYourStoreButton ).toBeInTheDocument(); diff --git a/client/settings/advanced-settings/debug-mode.js b/client/settings/advanced-settings/debug-mode.js index 3630ee33e39..f1b5a09c7a3 100644 --- a/client/settings/advanced-settings/debug-mode.js +++ b/client/settings/advanced-settings/debug-mode.js @@ -3,7 +3,6 @@ */ import { CheckboxControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useEffect, useRef } from '@wordpress/element'; /** * Internal dependencies @@ -13,17 +12,10 @@ import { useDebugLog, useDevMode } from 'wcpay/data'; const DebugMode = () => { const isDevModeEnabled = useDevMode(); const [ isLoggingChecked, setIsLoggingChecked ] = useDebugLog(); - const headingRef = useRef( null ); - - useEffect( () => { - if ( ! headingRef.current ) return; - - headingRef.current.focus(); - }, [] ); return ( <> - <h4 ref={ headingRef } tabIndex="-1"> + <h4 tabIndex="-1"> { __( 'Debug mode', 'woocommerce-payments' ) } </h4> <CheckboxControl diff --git a/client/settings/advanced-settings/index.js b/client/settings/advanced-settings/index.js index 080799015a8..d30759b02a6 100644 --- a/client/settings/advanced-settings/index.js +++ b/client/settings/advanced-settings/index.js @@ -2,52 +2,37 @@ * External dependencies */ import React from 'react'; -import { Icon, chevronDown, chevronUp } from '@wordpress/icons'; -import { __ } from '@wordpress/i18n'; -import { Card, Button } from '@wordpress/components'; +import { Card } from '@wordpress/components'; /** * Internal dependencies */ -import SettingsSection from '../settings-section'; import DebugMode from './debug-mode'; import MultiCurrencyToggle from './multi-currency-toggle'; import WCPaySubscriptionsToggle from './wcpay-subscriptions-toggle'; -import useToggle from './use-toggle'; import './style.scss'; import CardBody from '../card-body'; -import ErrorBoundary from '../../components/error-boundary'; import ClientSecretEncryptionToggle from './client-secret-encryption-toggle'; +import StripeBillingSection from './stripe-billing-section'; const AdvancedSettings = () => { - const [ isSectionExpanded, toggleIsSectionExpanded ] = useToggle( false ); - return ( <> - <SettingsSection> - <Button onClick={ toggleIsSectionExpanded } isTertiary> - { __( 'Advanced settings', 'wordpress-components' ) } - <Icon - icon={ isSectionExpanded ? chevronUp : chevronDown } - /> - </Button> - </SettingsSection> - { isSectionExpanded && ( - <SettingsSection> - <ErrorBoundary> - <Card> - <CardBody> - <MultiCurrencyToggle /> - { wcpaySettings.isClientEncryptionEligible && ( - <ClientSecretEncryptionToggle /> - ) } - <WCPaySubscriptionsToggle /> - <DebugMode /> - </CardBody> - </Card> - </ErrorBoundary> - </SettingsSection> - ) } + <Card> + <CardBody> + <MultiCurrencyToggle /> + { wcpaySettings.isClientEncryptionEligible && ( + <ClientSecretEncryptionToggle /> + ) } + { wcpaySettings.isSubscriptionsActive && + wcpaySettings.isStripeBillingEligible ? ( + <StripeBillingSection /> + ) : ( + <WCPaySubscriptionsToggle /> + ) } + <DebugMode /> + </CardBody> + </Card> </> ); }; diff --git a/client/settings/advanced-settings/interfaces.ts b/client/settings/advanced-settings/interfaces.ts new file mode 100644 index 00000000000..78cf985635d --- /dev/null +++ b/client/settings/advanced-settings/interfaces.ts @@ -0,0 +1,14 @@ +/** + * Interface exports + */ + +export type StripeBillingHook = [ boolean, ( value: boolean ) => void ]; + +export type StripeBillingMigrationHook = [ + boolean, + number, + number, + () => void, + boolean, + boolean +]; diff --git a/client/settings/advanced-settings/multi-currency-toggle.js b/client/settings/advanced-settings/multi-currency-toggle.js index 67c6e654a15..8f2cd300088 100644 --- a/client/settings/advanced-settings/multi-currency-toggle.js +++ b/client/settings/advanced-settings/multi-currency-toggle.js @@ -40,7 +40,7 @@ const MultiCurrencyToggle = () => { components: { learnMoreLink: ( // eslint-disable-next-line max-len - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/" /> + <ExternalLink href="https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/" /> ), }, } ) } diff --git a/client/settings/advanced-settings/stripe-billing-notices/context.tsx b/client/settings/advanced-settings/stripe-billing-notices/context.tsx new file mode 100644 index 00000000000..a18da2178f8 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-notices/context.tsx @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { createContext } from 'react'; + +const StripeBillingMigrationNoticeContext = createContext( { + isStripeBillingEnabled: false, + savedIsStripeBillingEnabled: false, + isMigrationOptionShown: false, + isMigrationInProgressShown: false, + isMigrationInProgress: false, + hasSavedSettings: false, + subscriptionCount: 0, + migratedCount: 0, + startMigration: () => null, + isResolvingMigrateRequest: false, + hasResolvedMigrateRequest: false, +} as { + isStripeBillingEnabled: boolean; + savedIsStripeBillingEnabled: boolean; + isMigrationOptionShown: boolean; + isMigrationInProgressShown: boolean; + isMigrationInProgress: boolean; + hasSavedSettings: boolean; + subscriptionCount: number; + migratedCount: number; + startMigration: () => void; + isResolvingMigrateRequest: boolean; + hasResolvedMigrateRequest: boolean; +} ); + +export default StripeBillingMigrationNoticeContext; diff --git a/client/settings/advanced-settings/stripe-billing-notices/migrate-automatically-notice.tsx b/client/settings/advanced-settings/stripe-billing-notices/migrate-automatically-notice.tsx new file mode 100644 index 00000000000..4cc69c9b077 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-notices/migrate-automatically-notice.tsx @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import React, { useState, useContext, useEffect } from 'react'; +import InlineNotice from 'wcpay/components/inline-notice'; +import { _n, sprintf } from '@wordpress/i18n'; +import { ExternalLink } from '@wordpress/components'; +import interpolateComponents from '@automattic/interpolate-components'; + +/** + * Internal dependencies + */ +import StripeBillingMigrationNoticeContext from './context'; + +interface Props { + /** + * The number of subscriptions that will be automatically migrated. + */ + stripeBillingSubscriptionCount: number; +} + +const MigrateAutomaticallyNotice: React.FC< Props > = ( { + stripeBillingSubscriptionCount, +} ) => { + const context = useContext( StripeBillingMigrationNoticeContext ); + + /** + * Whether the notice is eligible to be shown. + * + * Note: We use `useState` here to snapshot the setting value on load. + * This notice should only be shown if Stripe Billing was enabled on load. + */ + const [ isEligible, setIsEligible ] = useState( + context.isStripeBillingEnabled + ); + + // Set the notice to be eligible if Stripe Billing is saved as enabled. ie Once saved, disabling will automatically migrate. + useEffect( () => { + if ( context.hasSavedSettings ) { + setIsEligible( context.savedIsStripeBillingEnabled ); + } + }, [ context.hasSavedSettings, context.savedIsStripeBillingEnabled ] ); + + if ( ! isEligible ) { + return null; + } + + // Don't show the notice if the migration option is shown. + if ( context.isMigrationOptionShown ) { + return null; + } + + // Don't show the notice if there are no Stripe Billing subscriptions to migrate. + if ( stripeBillingSubscriptionCount === 0 ) { + return null; + } + + if ( context.isStripeBillingEnabled ) { + return null; + } + + return ( + <InlineNotice + status="warning" + isDismissible={ false } + className="woopayments-stripe-billing-notice" + > + { interpolateComponents( { + mixedString: sprintf( + _n( + 'There is currently %d customer subscription using Stripe Billing for payment processing.' + + ' This subscription will be automatically migrated to use the on-site billing engine' + + ' built into %s once Stripe Billing is disabled.' + + ' {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'There are currently %d customer subscriptions using Stripe Billing for payment processing.' + + ' These subscriptions will be automatically migrated to use the on-site billing engine' + + ' built into %s once Stripe Billing is disabled.' + + ' {{learnMoreLink}}Learn more{{/learnMoreLink}}', + stripeBillingSubscriptionCount, + 'woocommerce-payments' + ), + stripeBillingSubscriptionCount, + 'Woo Subscriptions' + ), + components: { + learnMoreLink: ( + // eslint-disable-next-line max-len + <ExternalLink href="https://woocommerce.com/document/woopayments/built-in-subscriptions/comparison/" /> + ), + }, + } ) } + </InlineNotice> + ); +}; + +export default MigrateAutomaticallyNotice; diff --git a/client/settings/advanced-settings/stripe-billing-notices/migrate-completed-notice.tsx b/client/settings/advanced-settings/stripe-billing-notices/migrate-completed-notice.tsx new file mode 100644 index 00000000000..701dbb14e19 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-notices/migrate-completed-notice.tsx @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import React, { useState, useContext } from 'react'; +import InlineNotice from 'wcpay/components/inline-notice'; +import { _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import StripeBillingMigrationNoticeContext from './context'; + +interface Props { + /** + * The number of subscriptions that have been migrated. + */ + completedMigrationCount: number; +} + +const MigrationCompletedNotice: React.FC< Props > = ( { + completedMigrationCount, +} ) => { + const [ isDismissed, setIsDismissed ] = useState( false ); + const context = useContext( StripeBillingMigrationNoticeContext ); + + /** + * Whether the notice is eligible to be shown. + * + * Note: We use `useState` here to snapshot the setting value on load. + * This "completed" notice should only be shown if Stripe billing was disabled on load and there there's no migration in progress. + */ + const [ isEligible ] = useState( + ! context.isStripeBillingEnabled && ! context.isMigrationInProgress + ); + + if ( ! isEligible || isDismissed || completedMigrationCount === 0 ) { + return null; + } + + return ( + <InlineNotice + status="info" + isDismissible={ true } + onRemove={ () => setIsDismissed( true ) } + className="woopayments-stripe-billing-notice" + > + { sprintf( + _n( + '%d customer subscription was successfully migrated from Stripe off-site billing to on-site billing' + + ' powered by %s and %s.', + '%d customer subscriptions were successfully migrated from Stripe off-site billing to on-site billing' + + ' powered by %s and %s.', + completedMigrationCount, + 'woocommerce-payments' + ), + completedMigrationCount, + 'Woo Subscriptions', + 'WooPayments' + ) } + </InlineNotice> + ); +}; + +export default MigrationCompletedNotice; diff --git a/client/settings/advanced-settings/stripe-billing-notices/migrate-option-notice.tsx b/client/settings/advanced-settings/stripe-billing-notices/migrate-option-notice.tsx new file mode 100644 index 00000000000..96b94df0081 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-notices/migrate-option-notice.tsx @@ -0,0 +1,145 @@ +/** + * External dependencies + */ +import React, { useContext, useState } from 'react'; +import InlineNotice from 'wcpay/components/inline-notice'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { ExternalLink } from '@wordpress/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import { useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import StripeBillingMigrationNoticeContext from './context'; + +interface Props { + /** + * The number of subscriptions that will be migrated if a migration is started. + */ + stripeBillingSubscriptionCount: number; + + /** + * The function to call to start a migration. + */ + startMigration: () => void; + + /** + * Whether the request to start a migration is loading. + */ + isLoading: boolean; + + /** + * Whether the request to start a migration has finished. + */ + hasResolved: boolean; +} + +const MigrateOptionNotice: React.FC< Props > = ( { + stripeBillingSubscriptionCount, + startMigration, + isLoading, + hasResolved, +} ) => { + const context = useContext( StripeBillingMigrationNoticeContext ); + + /** + * Whether the notice is eligible to be shown. + * + * Note: We use `useState` here to snapshot the setting value on load. + * The option notice should only be shown if Stripe Billing is disabled on load and there are subscriptions to migrate. + */ + const [ isEligible, setIsEligible ] = useState( + ! context.isStripeBillingEnabled + ); + + // The class name of the action which sends the request to migrate. + const noticeClassName = 'woopayments-migrate-stripe-billing-action'; + + // Add the `is-busy` class to the button while we process the migrate request. + useEffect( () => { + const button = document.querySelector( + `.${ noticeClassName } .wcpay-inline-notice__action` + ); + + if ( button ) { + if ( isLoading ) { + button.classList.add( 'is-busy' ); + } else { + button.classList.remove( 'is-busy' ); + } + } + }, [ isLoading ] ); + + // The notice is no longer eligible if the settings have been saved and Stripe Billing is enabled. + useEffect( () => { + if ( context.savedIsStripeBillingEnabled ) { + setIsEligible( false ); + } + }, [ context.savedIsStripeBillingEnabled ] ); + + // Once the request is resolved, hide the notice and mark the migration as in progress. + if ( hasResolved ) { + context.isMigrationInProgress = true; + context.isMigrationOptionShown = false; + return null; + } + + if ( context.isMigrationInProgress ) { + return null; + } + + if ( stripeBillingSubscriptionCount === 0 ) { + return null; + } + + if ( ! isEligible ) { + return null; + } + + if ( context.isStripeBillingEnabled ) { + return null; + } + + // Update the context to note the Option Notice is being shown. + context.isMigrationOptionShown = true; + + return ( + <InlineNotice + status="warning" + isDismissible={ false } + className={ `woopayments-stripe-billing-notice ${ noticeClassName }` } + actions={ [ + { + label: __( 'Begin migration', 'woocommerce-payments' ), + onClick: startMigration, + }, + ] } + > + { interpolateComponents( { + mixedString: sprintf( + _n( + 'There is %d customer subscription using Stripe Billing for subscription renewals.' + + ' We suggest migrating it to on-site billing powered by the %s plugin.' + + ' {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'There are %d customer subscriptions using Stripe Billing for payment processing.' + + ' We suggest migrating them to on-site billing powered by the %s plugin.' + + ' {{learnMoreLink}}Learn more{{/learnMoreLink}}', + stripeBillingSubscriptionCount, + 'woocommerce-payments' + ), + stripeBillingSubscriptionCount, + 'Woo Subscriptions' + ), + components: { + learnMoreLink: ( + // eslint-disable-next-line max-len + <ExternalLink href="https://woocommerce.com/document/woopayments/built-in-subscriptions/comparison/" /> + ), + }, + } ) } + </InlineNotice> + ); +}; + +export default MigrateOptionNotice; diff --git a/client/settings/advanced-settings/stripe-billing-notices/migration-progress-notice.tsx b/client/settings/advanced-settings/stripe-billing-notices/migration-progress-notice.tsx new file mode 100644 index 00000000000..9079f3208d0 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-notices/migration-progress-notice.tsx @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import React, { useState, useContext, useEffect } from 'react'; +import InlineNotice from 'wcpay/components/inline-notice'; +import { _n, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import StripeBillingMigrationNoticeContext from './context'; + +interface Props { + /** + * The number of subscriptions that are being migrated. + */ + stripeBillingSubscriptionCount: number; +} + +const MigrationInProgressNotice: React.FC< Props > = ( { + stripeBillingSubscriptionCount, +} ) => { + const [ isDismissed, setIsDismissed ] = useState( false ); + + const context = useContext( StripeBillingMigrationNoticeContext ); + + /** + * Whether the notice is eligible to be shown. + * + * Note: We use `useState` here to snapshot the setting value on load. + * + * This notice should only be shown if a migration is in progress. + * The migration is in progress if the settings have been saved and Stripe Billing is disabled or if the migration option is clicked. + */ + const [ isEligible, setIsEligible ] = useState( + context.isMigrationInProgress + ); + + // Set the notice to be eligible if the user has chosen to migrate. + useEffect( () => { + if ( context.hasResolvedMigrateRequest ) { + setIsEligible( true ); + } + }, [ context.hasResolvedMigrateRequest ] ); + + // Set the notice to be eligible if Stripe Billing is saved as disabled. When disabling Stripe Billing, the migration will automatically start. + useEffect( () => { + if ( context.hasSavedSettings ) { + setIsEligible( ! context.savedIsStripeBillingEnabled ); + } + }, [ context.hasSavedSettings, context.savedIsStripeBillingEnabled ] ); + + // Don't show the notice if it's not eligible. + if ( ! isEligible ) { + return null; + } + + // Don't show the notice if it has been dismissed. + if ( isDismissed ) { + return null; + } + + if ( context.subscriptionCount === 0 ) { + return null; + } + + // Don't show the notice if the migration option is shown. + if ( context.isMigrationOptionShown ) { + return null; + } + + // Mark the notice as shown. + context.isMigrationInProgressShown = true; + + return ( + <InlineNotice + status="info" + isDismissible={ true } + onRemove={ () => setIsDismissed( true ) } + className="woopayments-stripe-billing-notice" + > + { sprintf( + _n( + '%d customer subscription is being migrated from Stripe off-site billing to billing powered by' + + ' %s and %s.', + '%d customer subscriptions are being migrated from Stripe off-site billing to billing powered by' + + ' %s and %s.', + stripeBillingSubscriptionCount, + 'woocommerce-payments' + ), + stripeBillingSubscriptionCount, + 'Woo Subscriptions', + 'WooPayments' + ) } + </InlineNotice> + ); +}; + +export default MigrationInProgressNotice; diff --git a/client/settings/advanced-settings/stripe-billing-notices/notices.tsx b/client/settings/advanced-settings/stripe-billing-notices/notices.tsx new file mode 100644 index 00000000000..5e4ba188375 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-notices/notices.tsx @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import React, { useContext } from 'react'; + +/** + * Internal dependencies + */ +import StripeBillingMigrationNoticeContext from './context'; +import MigrationInProgressNotice from './migration-progress-notice'; +import MigrateOptionNotice from './migrate-option-notice'; +import MigrateAutomaticallyNotice from './migrate-automatically-notice'; +import MigrationCompletedNotice from './migrate-completed-notice'; +import './style.scss'; + +/** + * Renders the Stripe Billing notices. + * + * @return {JSX.Element} Rendered notices. + */ +const Notices: React.FC = () => { + const context = useContext( StripeBillingMigrationNoticeContext ); + + return ( + <> + <MigrationCompletedNotice + completedMigrationCount={ context.migratedCount } + /> + <MigrateOptionNotice + stripeBillingSubscriptionCount={ context.subscriptionCount } + startMigration={ () => { + context.startMigration(); + } } + isLoading={ context.isResolvingMigrateRequest } + hasResolved={ context.hasResolvedMigrateRequest } + /> + <MigrateAutomaticallyNotice + stripeBillingSubscriptionCount={ context.subscriptionCount } + /> + <MigrationInProgressNotice + stripeBillingSubscriptionCount={ context.subscriptionCount } + /> + </> + ); +}; + +export default Notices; diff --git a/client/settings/advanced-settings/stripe-billing-notices/style.scss b/client/settings/advanced-settings/stripe-billing-notices/style.scss new file mode 100644 index 00000000000..01482a52ce6 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-notices/style.scss @@ -0,0 +1,4 @@ +.wcpay-inline-notice.components-notice.woopayments-stripe-billing-notice { + margin-top: 0; + margin-bottom: 1em; +} diff --git a/client/settings/advanced-settings/stripe-billing-section.tsx b/client/settings/advanced-settings/stripe-billing-section.tsx new file mode 100644 index 00000000000..bd0e3ce270f --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-section.tsx @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import React, { useState, useEffect } from 'react'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { + useStripeBilling, + useStripeBillingMigration, + useSettings, +} from 'wcpay/data'; +import Notices from './stripe-billing-notices/notices'; +import StripeBillingMigrationNoticeContext from './stripe-billing-notices/context'; +import StripeBillingToggle from './stripe-billing-toggle'; +import { StripeBillingHook, StripeBillingMigrationHook } from './interfaces'; + +/** + * Renders a WooPayments Subscriptions Advanced Settings Section. + * + * @return {JSX.Element} Rendered subscriptions advanced settings section. + */ +const StripeBillingSection: React.FC = () => { + const [ + isStripeBillingEnabled, + updateIsStripeBillingEnabled, + ] = useStripeBilling() as StripeBillingHook; + const [ + isMigrationInProgress, + migratedCount, + subscriptionCount, + startMigration, + isResolving, + hasResolved, + ] = useStripeBillingMigration() as StripeBillingMigrationHook; + + /** + * Notices are shown and hidden based on whether the settings have been saved. + * The following variables track the saving state of the WooPayments settings. + */ + const { isLoading, isSaving } = useSettings(); + const [ hasSavedSettings, setHasSavedSettings ] = useState( false ); + const [ + savedIsStripeBillingEnabled, + setSavedIsStripeBillingEnabled, + ] = useState( isStripeBillingEnabled ); + + // The settings have finished saving when the settings are not actively being saved and we've flagged they were being saved. + const hasFinishedSavingSettings = ! isSaving && hasSavedSettings; + + // When the settings are being saved, set the hasSavedSettings flag to true. + useEffect( () => { + if ( isSaving && ! isLoading ) { + setHasSavedSettings( true ); + } + }, [ isLoading, isSaving ] ); + + // When the settings have finished saving, update the savedIsStripeBillingEnabled value. + useEffect( () => { + if ( hasFinishedSavingSettings ) { + setSavedIsStripeBillingEnabled( isStripeBillingEnabled ); + } + }, [ hasFinishedSavingSettings, isStripeBillingEnabled ] ); + + // Set up the context to be shared between the notices and the toggle. + const [ isMigrationInProgressShown ] = useState( false ); + const [ isMigrationOptionShown ] = useState( false ); + + const noticeContext = { + isStripeBillingEnabled: isStripeBillingEnabled, + savedIsStripeBillingEnabled: savedIsStripeBillingEnabled, + + // Notice logic. + isMigrationOptionShown: isMigrationOptionShown, + isMigrationInProgressShown: isMigrationInProgressShown, + + // Migration logic. + isMigrationInProgress: isMigrationInProgress, + hasSavedSettings: hasFinishedSavingSettings, + + // Migration data. + subscriptionCount: subscriptionCount, + migratedCount: migratedCount, + + // Migration actions & state. + startMigration: startMigration, + isResolvingMigrateRequest: isResolving, + hasResolvedMigrateRequest: hasResolved, + }; + + // When the toggle is changed, update the WooPayments settings and reset the hasSavedSettings flag. + const stripeBillingSettingToggle = ( enabled: boolean ) => { + updateIsStripeBillingEnabled( enabled ); + setHasSavedSettings( false ); + }; + + return ( + <StripeBillingMigrationNoticeContext.Provider value={ noticeContext }> + <h4>{ __( 'Subscriptions', 'woocommerce-payments' ) }</h4> + <Notices /> + <StripeBillingToggle onChange={ stripeBillingSettingToggle } /> + </StripeBillingMigrationNoticeContext.Provider> + ); +}; + +export default StripeBillingSection; diff --git a/client/settings/advanced-settings/stripe-billing-toggle.tsx b/client/settings/advanced-settings/stripe-billing-toggle.tsx new file mode 100644 index 00000000000..4f8dea69584 --- /dev/null +++ b/client/settings/advanced-settings/stripe-billing-toggle.tsx @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import React, { useContext } from 'react'; +import { __, sprintf } from '@wordpress/i18n'; +import { CheckboxControl, ExternalLink } from '@wordpress/components'; +import interpolateComponents from '@automattic/interpolate-components'; + +/** + * Internal dependencies + */ +import StripeBillingMigrationNoticeContext from './stripe-billing-notices/context'; + +interface Props { + /** + * The function to run when the checkbox is changed. + */ + onChange: ( enabled: boolean ) => void; +} + +/** + * Renders the Stripe Billing toggle. + * + * @return {JSX.Element} Rendered Stripe Billing toggle. + */ +const StripeBillingToggle: React.FC< Props > = ( { onChange } ) => { + const context = useContext( StripeBillingMigrationNoticeContext ); + + return ( + <CheckboxControl + checked={ context.isStripeBillingEnabled } + onChange={ onChange } + label={ __( + 'Enable Stripe Billing for future subscriptions', + 'woocommerce-payments' + ) } + help={ interpolateComponents( { + mixedString: sprintf( + context.isMigrationOptionShown && + context.migratedCount === 0 + ? __( + 'Alternatively, you can enable this setting and future %s subscription purchases will also utilize' + + ' Stripe Billing for payment processing. Note: This feature supports card payments only and' + + ' may lack support for key subscription features.' + + ' {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'woocommerce-payments' + ) + : __( + 'By enabling this setting, future %s subscription purchases will utilize Stripe Billing for payment' + + ' processing. Note: This feature supports card payments only and may lack support for key' + + ' subscription features. {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'woocommerce-payments' + ), + 'WooPayments' + ), + components: { + learnMoreLink: ( + // eslint-disable-next-line max-len + <ExternalLink href="https://woocommerce.com/document/woopayments/built-in-subscriptions/comparison/#billing-engine" /> + ), + }, + } ) } + /> + ); +}; + +export default StripeBillingToggle; diff --git a/client/settings/advanced-settings/test/debug-mode.test.js b/client/settings/advanced-settings/test/debug-mode.test.js index abd59036994..54877787745 100644 --- a/client/settings/advanced-settings/test/debug-mode.test.js +++ b/client/settings/advanced-settings/test/debug-mode.test.js @@ -22,12 +22,6 @@ describe( 'DebugMode', () => { jest.clearAllMocks(); } ); - it( 'sets the heading as focused after rendering', () => { - render( <DebugMode /> ); - - expect( screen.getByText( 'Debug mode' ) ).toHaveFocus(); - } ); - it( 'toggles the logging checkbox', () => { const setDebugLogMock = jest.fn(); useDebugLog.mockReturnValue( [ false, setDebugLogMock ] ); diff --git a/client/settings/advanced-settings/test/index.test.js b/client/settings/advanced-settings/test/index.test.js index 05be0739f75..517f237969e 100644 --- a/client/settings/advanced-settings/test/index.test.js +++ b/client/settings/advanced-settings/test/index.test.js @@ -4,44 +4,59 @@ * External dependencies */ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; /** * Internal dependencies */ import AdvancedSettings from '..'; +import { + useMultiCurrency, + useWCPaySubscriptions, + useDevMode, + useDebugLog, + useClientSecretEncryption, +} from 'wcpay/data'; + +jest.mock( '../../../data', () => ( { + useSettings: jest.fn(), + useMultiCurrency: jest.fn(), + useWCPaySubscriptions: jest.fn(), + useDevMode: jest.fn(), + useDebugLog: jest.fn(), + useClientSecretEncryption: jest.fn(), +} ) ); describe( 'AdvancedSettings', () => { - it( 'toggles the advanced settings section', () => { + beforeEach( () => { + useMultiCurrency.mockReturnValue( [ false, jest.fn() ] ); + useWCPaySubscriptions.mockReturnValue( [ false, jest.fn() ] ); + useDevMode.mockReturnValue( false ); + useDebugLog.mockReturnValue( [ false, jest.fn() ] ); + useClientSecretEncryption.mockReturnValue( [ false, jest.fn() ] ); + } ); + test( 'toggles the advanced settings section', () => { global.wcpaySettings = { isClientEncryptionEligible: true, }; - render( <AdvancedSettings /> ); - expect( screen.queryByText( 'Debug mode' ) ).not.toBeInTheDocument(); - - userEvent.click( screen.getByText( 'Advanced settings' ) ); + render( <AdvancedSettings /> ); + // The advanced settings section is expanded by default. expect( screen.queryByText( 'Enable Public Key Encryption' ) ).toBeInTheDocument(); expect( screen.queryByText( 'Debug mode' ) ).toBeInTheDocument(); - expect( screen.getByText( 'Debug mode' ) ).toHaveFocus(); } ); - it( 'hides the client encryption toggle when not eligible', () => { + test( 'hides the client encryption toggle when not eligible', () => { global.wcpaySettings = { isClientEncryptionEligible: false, }; - render( <AdvancedSettings /> ); - - expect( screen.queryByText( 'Debug mode' ) ).not.toBeInTheDocument(); - userEvent.click( screen.getByText( 'Advanced settings' ) ); + render( <AdvancedSettings /> ); expect( screen.queryByText( 'Enable Public Key Encryption' ) ).not.toBeInTheDocument(); expect( screen.queryByText( 'Debug mode' ) ).toBeInTheDocument(); - expect( screen.getByText( 'Debug mode' ) ).toHaveFocus(); } ); } ); diff --git a/client/settings/advanced-settings/wcpay-subscriptions-toggle.js b/client/settings/advanced-settings/wcpay-subscriptions-toggle.js index 72734e157aa..0f1f3f2937b 100644 --- a/client/settings/advanced-settings/wcpay-subscriptions-toggle.js +++ b/client/settings/advanced-settings/wcpay-subscriptions-toggle.js @@ -15,7 +15,6 @@ const WCPaySubscriptionsToggle = () => { const [ isWCPaySubscriptionsEnabled, isWCPaySubscriptionsEligible, - isSubscriptionsPluginActive, updateIsWCPaySubscriptionsEnabled, ] = useWCPaySubscriptions(); @@ -31,7 +30,11 @@ const WCPaySubscriptionsToggle = () => { updateIsWCPaySubscriptionsEnabled( value ); }; - return ! isSubscriptionsPluginActive && + /** + * Only show the toggle if the site doesn't have WC Subscriptions active and is eligible + * for wcpay subscriptions or if wcpay subscriptions are already enabled. + */ + return ! wcpaySettings.isSubscriptionsActive && ( isWCPaySubscriptionsEligible || isWCPaySubscriptionsEnabled ) ? ( <CheckboxControl label={ sprintf( @@ -51,7 +54,7 @@ const WCPaySubscriptionsToggle = () => { components: { learnMoreLink: ( // eslint-disable-next-line max-len - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/built-in-subscriptions/" /> + <ExternalLink href="https://woocommerce.com/document/woopayments/built-in-subscriptions/" /> ), }, } ) } diff --git a/client/settings/deposits/index.js b/client/settings/deposits/index.js index e810f32ba05..5c633a390e0 100644 --- a/client/settings/deposits/index.js +++ b/client/settings/deposits/index.js @@ -176,7 +176,7 @@ const DepositsSchedule = () => { 'Learn more about deposit scheduling.', 'woocommerce-payments' ) } - href="https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule/" + href="https://woocommerce.com/document/woopayments/deposits/deposit-schedule/" target="_blank" rel="external noreferrer noopener" > @@ -203,7 +203,7 @@ const DepositsSchedule = () => { 'Learn more about deposit scheduling.', 'woocommerce-payments' ) } - href="https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule/" + href="https://woocommerce.com/document/woopayments/deposits/deposit-schedule/" target="_blank" rel="external noreferrer noopener" > diff --git a/client/settings/disable-upe-modal/index.js b/client/settings/disable-upe-modal/index.js index 8b5992f771b..4dc089783f3 100644 --- a/client/settings/disable-upe-modal/index.js +++ b/client/settings/disable-upe-modal/index.js @@ -14,13 +14,13 @@ import './style.scss'; import ConfirmationModal from 'components/confirmation-modal'; import useIsUpeEnabled from 'settings/wcpay-upe-toggle/hook'; import WcPayUpeContext from 'settings/wcpay-upe-toggle/context'; -import InlineNotice from '../../components/inline-notice'; +import InlineNotice from 'components/inline-notice'; import { useEnabledPaymentMethodIds } from '../../data'; import PaymentMethodIcon from '../payment-method-icon'; const NeedHelpBarSection = () => { return ( - <InlineNotice status="info" isDismissible={ false }> + <InlineNotice icon status="info" isDismissible={ false }> { interpolateComponents( { mixedString: __( 'Need help? Visit {{ docsLink /}} or {{supportLink /}}.', @@ -29,7 +29,7 @@ const NeedHelpBarSection = () => { components: { docsLink: ( // eslint-disable-next-line max-len - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/payment-methods/additional-payment-methods/"> + <ExternalLink href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/"> { sprintf( /* translators: %s: WooPayments */ __( '%s docs', 'woocommerce-payments' ), diff --git a/client/settings/express-checkout-settings/index.scss b/client/settings/express-checkout-settings/index.scss index 8db0f6cbbbe..256f77588f8 100644 --- a/client/settings/express-checkout-settings/index.scss +++ b/client/settings/express-checkout-settings/index.scss @@ -61,7 +61,6 @@ .woopay-settings { &__custom-message-wrapper { - max-width: 500px; position: relative; .components-base-control__field .components-text-control__input { diff --git a/client/settings/express-checkout-settings/payment-request-button-preview.js b/client/settings/express-checkout-settings/payment-request-button-preview.js index 61472d48c91..ed5a865fee0 100644 --- a/client/settings/express-checkout-settings/payment-request-button-preview.js +++ b/client/settings/express-checkout-settings/payment-request-button-preview.js @@ -152,7 +152,7 @@ const PaymentRequestButtonPreview = () => { </div> ) } { ! isWooPayEnabled && ! isPaymentRequestEnabled && ( - <InlineNotice status="info" isDismissible={ false }> + <InlineNotice icon status="info" isDismissible={ false }> { __( 'To preview the express checkout buttons, ' + 'activate at least one express checkout.', @@ -161,7 +161,7 @@ const PaymentRequestButtonPreview = () => { </InlineNotice> ) } { isPaymentRequestEnabled && ! isLoading && ! paymentRequest && ( - <InlineNotice status="info" isDismissible={ false }> + <InlineNotice icon status="info" isDismissible={ false }> { __( 'To preview the Apple Pay and Google Pay buttons, ' + 'ensure your device is configured to accept Apple Pay or Google Pay, ' + diff --git a/client/settings/express-checkout-settings/woopay-settings.js b/client/settings/express-checkout-settings/woopay-settings.js index 5e54975dd63..e725270822c 100644 --- a/client/settings/express-checkout-settings/woopay-settings.js +++ b/client/settings/express-checkout-settings/woopay-settings.js @@ -4,7 +4,12 @@ */ import React from 'react'; import { __ } from '@wordpress/i18n'; -import { Card, CheckboxControl, TextareaControl } from '@wordpress/components'; +import { + Card, + CheckboxControl, + TextareaControl, + ExternalLink, +} from '@wordpress/components'; import interpolateComponents from '@automattic/interpolate-components'; import { Link } from '@woocommerce/components'; @@ -213,19 +218,24 @@ const WooPaySettings = ( { section } ) => { help={ interpolateComponents( { mixedString: __( 'Override the default {{privacyLink}}privacy policy{{/privacyLink}}' + - ' and {{termsLink}}terms of service{{/termsLink}}, or add custom text to WooPay checkout.', + ' and {{termsLink}}terms of service{{/termsLink}},' + + ' or add custom text to WooPay checkout. {{learnMoreLink}}Learn more{{/learnMoreLink}}.', 'woocommerce-payments' ), // prettier-ignore components: { /* eslint-disable prettier/prettier */ privacyLink: window.wcSettings?.storePages?.privacy?.permalink ? - <Link href={ window.wcSettings?.storePages?.privacy?.permalink } type="external" /> : + <Link href={ window.wcSettings.storePages.privacy.permalink } type="external" /> : <span />, termsLink: window.wcSettings?.storePages?.terms?.permalink ? - <Link href={ window.wcSettings?.storePages?.terms?.permalink } type="external" /> : + <Link href={ window.wcSettings.storePages.terms.permalink } type="external" /> : <span />, /* eslint-enable prettier/prettier */ + learnMoreLink: ( + // eslint-disable-next-line max-len + <ExternalLink href="https://woocommerce.com/document/woopay-merchant-documentation/#checkout-appearance" /> + ), } } ) } value={ woopayCustomMessage } diff --git a/client/settings/express-checkout/apple-google-pay-item.tsx b/client/settings/express-checkout/apple-google-pay-item.tsx index 871bbf6ee22..dd9f8f0c078 100644 --- a/client/settings/express-checkout/apple-google-pay-item.tsx +++ b/client/settings/express-checkout/apple-google-pay-item.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { CheckboxControl } from '@wordpress/components'; +import { Button, CheckboxControl } from '@wordpress/components'; import interpolateComponents from '@automattic/interpolate-components'; import React from 'react'; @@ -150,13 +150,14 @@ const AppleGooglePayExpressCheckoutItem = (): React.ReactElement => { </div> </div> <div className="express-checkout__link"> - <a + <Button href={ getPaymentMethodSettingsUrl( 'payment_request' ) } + isSecondary > { __( 'Customize', 'woocommerce-payments' ) } - </a> + </Button> </div> </div> </div> diff --git a/client/settings/express-checkout/interfaces.ts b/client/settings/express-checkout/interfaces.ts index e76832a470e..bf32c983829 100644 --- a/client/settings/express-checkout/interfaces.ts +++ b/client/settings/express-checkout/interfaces.ts @@ -6,3 +6,10 @@ export type PaymentRequestEnabledSettingsHook = [ boolean, ( value: boolean ) => void ]; + +export type EnabledMethodIdsHook = [ + Array< string >, + ( value: Array< string > ) => void +]; + +export type WooPayEnabledSettingsHook = [ boolean, ( value: boolean ) => void ]; diff --git a/client/settings/express-checkout/link-item.js b/client/settings/express-checkout/link-item.tsx similarity index 82% rename from client/settings/express-checkout/link-item.js rename to client/settings/express-checkout/link-item.tsx index 19304e668c0..f0efbf901ee 100644 --- a/client/settings/express-checkout/link-item.js +++ b/client/settings/express-checkout/link-item.tsx @@ -1,8 +1,9 @@ /** * External dependencies */ +import React from 'react'; import { __ } from '@wordpress/i18n'; -import { CheckboxControl, VisuallyHidden } from '@wordpress/components'; +import { Button, CheckboxControl, VisuallyHidden } from '@wordpress/components'; import interpolateComponents from '@automattic/interpolate-components'; /** @@ -17,18 +18,21 @@ import './style.scss'; import { HoverTooltip } from 'components/tooltip'; import LinkIcon from 'assets/images/payment-methods/link.svg?asset'; import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; +import { EnabledMethodIdsHook } from './interfaces'; -const LinkExpressCheckoutItem = () => { - const availablePaymentMethodIds = useGetAvailablePaymentMethodIds(); +const LinkExpressCheckoutItem = (): React.ReactElement => { + const availablePaymentMethodIds = useGetAvailablePaymentMethodIds() as Array< + string + >; const [ isWooPayEnabled ] = useWooPayEnabledSettings(); const [ enabledMethodIds, updateEnabledMethodIds, - ] = useEnabledPaymentMethodIds(); + ] = useEnabledPaymentMethodIds() as EnabledMethodIdsHook; - const updateStripeLinkCheckout = ( isEnabled ) => { + const updateStripeLinkCheckout = ( isEnabled: boolean ) => { //this handles the link payment method checkbox. If it's enable we should add link to the rest of the //enabled payment method. // If false - we should remove link payment method from the enabled payment methods @@ -62,15 +66,7 @@ const LinkExpressCheckoutItem = () => { ) } > <div className="loadable-checkbox__icon"> - <NoticeOutlineIcon - style={ { - color: '#F0B849', - fill: 'currentColor', - marginBottom: '-5px', - marginRight: '16px', - } } - size={ 20 } - /> + <NoticeOutlineIcon /> <div className="loadable-checkbox__icon-warning" data-testid="loadable-checkbox-icon-warning" @@ -158,26 +154,18 @@ const LinkExpressCheckoutItem = () => { </div> </div> <div className="express-checkout__link"> - { - /* eslint-disable jsx-a11y/anchor-has-content */ - interpolateComponents( { - mixedString: __( - '{{linkDocs}}Read more{{/linkDocs}}', - 'woocommerce-payments' - ), - components: { - linkDocs: ( - <a - target="_blank" - rel="noreferrer" - /* eslint-disable-next-line max-len */ - href="https://woocommerce.com/document/woocommerce-payments/payment-methods/link-by-stripe/" - /> - ), - }, - } ) - /* eslint-enable jsx-a11y/anchor-has-content */ - } + <Button + target="_blank" + rel="noreferrer" + /* eslint-disable-next-line max-len */ + href="https://woocommerce.com/document/woopayments/payment-methods/link-by-stripe/" + isSecondary + > + { __( + 'Read more', + 'woocommerce-payments' + ) } + </Button> </div> </div> </div> diff --git a/client/settings/express-checkout/style.scss b/client/settings/express-checkout/style.scss index 82c928c175b..3854f058277 100644 --- a/client/settings/express-checkout/style.scss +++ b/client/settings/express-checkout/style.scss @@ -9,6 +9,12 @@ padding: 24px; background: #fff; + .gridicons-notice-outline { + fill: #f0b849; + margin-bottom: -5px; + margin-right: 16px; + } + &__label-container { display: flex; flex-wrap: wrap; @@ -129,18 +135,8 @@ } &__link { - padding: 12px; - border: 1px solid #007cba; - border-radius: 2px; - font-size: 12px; - height: 18px; align-self: center; - a { - text-decoration: none; - white-space: nowrap; - } - @include breakpoint( '<660px' ) { align-self: flex-start; margin-top: 20px; diff --git a/client/settings/express-checkout/woopay-item.js b/client/settings/express-checkout/woopay-item.tsx similarity index 92% rename from client/settings/express-checkout/woopay-item.js rename to client/settings/express-checkout/woopay-item.tsx index 8bc159ab2d5..26f19ecebf2 100644 --- a/client/settings/express-checkout/woopay-item.js +++ b/client/settings/express-checkout/woopay-item.tsx @@ -1,8 +1,10 @@ /** * External dependencies */ + +import React from 'react'; import { __ } from '@wordpress/i18n'; -import { CheckboxControl, VisuallyHidden } from '@wordpress/components'; +import { Button, CheckboxControl, VisuallyHidden } from '@wordpress/components'; import WooIcon from 'assets/images/payment-methods/woo.svg?asset'; import interpolateComponents from '@automattic/interpolate-components'; import { getPaymentMethodSettingsUrl } from '../../utils'; @@ -21,13 +23,17 @@ import WCPaySettingsContext from '../wcpay-settings-context'; import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; import WooPayIncompatibilityNotice from '../settings-warnings/incompatibility-notice'; -const WooPayExpressCheckoutItem = () => { - const [ enabledMethodIds ] = useEnabledPaymentMethodIds(); +import { WooPayEnabledSettingsHook } from './interfaces'; + +const WooPayExpressCheckoutItem = (): React.ReactElement => { + const [ enabledMethodIds ] = useEnabledPaymentMethodIds() as Array< + string + >; const [ isWooPayEnabled, updateIsWooPayEnabled, - ] = useWooPayEnabledSettings(); + ] = useWooPayEnabledSettings() as WooPayEnabledSettingsHook; const showIncompatibilityNotice = useWooPayShowIncompatibilityNotice(); @@ -51,15 +57,7 @@ const WooPayExpressCheckoutItem = () => { ) } > <div className="loadable-checkbox__icon"> - <NoticeOutlineIcon - style={ { - color: '#F0B849', - fill: 'currentColor', - marginBottom: '-5px', - marginRight: '16px', - } } - size={ 20 } - /> + <NoticeOutlineIcon /> <div className="loadable-checkbox__icon-warning" data-testid="loadable-checkbox-icon-warning" @@ -164,16 +162,17 @@ const WooPayExpressCheckoutItem = () => { </div> <div className="express-checkout__link"> - <a + <Button href={ getPaymentMethodSettingsUrl( 'woopay' ) } + isSecondary > { __( 'Customize', 'woocommerce-payments' ) } - </a> + </Button> </div> </div> </div> diff --git a/client/settings/fraud-protection/advanced-settings/cards/cvc-verification.tsx b/client/settings/fraud-protection/advanced-settings/cards/cvc-verification.tsx index 393d058abc1..e3d1826640b 100644 --- a/client/settings/fraud-protection/advanced-settings/cards/cvc-verification.tsx +++ b/client/settings/fraud-protection/advanced-settings/cards/cvc-verification.tsx @@ -47,7 +47,7 @@ const CVCVerificationRuleCard: React.FC = () => { target="_blank" type="external" // eslint-disable-next-line max-len - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" /> ), }, diff --git a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/cvc-verification.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/cvc-verification.test.tsx.snap index 492f9cde67e..2d9cdd41659 100644 --- a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/cvc-verification.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/cvc-verification.test.tsx.snap @@ -46,7 +46,7 @@ exports[`CVC verification card renders correctly when CVC check is disabled 1`] </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -57,7 +57,7 @@ exports[`CVC verification card renders correctly when CVC check is disabled 1`] data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -76,7 +76,7 @@ exports[`CVC verification card renders correctly when CVC check is disabled 1`] </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -152,7 +152,7 @@ exports[`CVC verification card renders correctly when CVC check is enabled 1`] = </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -163,7 +163,7 @@ exports[`CVC verification card renders correctly when CVC check is enabled 1`] = data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -182,14 +182,14 @@ exports[`CVC verification card renders correctly when CVC check is enabled 1`] = </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more diff --git a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/international-ip-address.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/international-ip-address.test.tsx.snap index 5347955901d..55d7b04c106 100644 --- a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/international-ip-address.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/international-ip-address.test.tsx.snap @@ -103,7 +103,7 @@ exports[`International IP address card renders correctly 1`] = ` </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -114,7 +114,7 @@ exports[`International IP address card renders correctly 1`] = ` data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -133,7 +133,7 @@ exports[`International IP address card renders correctly 1`] = ` </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -267,7 +267,7 @@ exports[`International IP address card renders correctly when enabled 1`] = ` </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -278,7 +278,7 @@ exports[`International IP address card renders correctly when enabled 1`] = ` data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -297,7 +297,7 @@ exports[`International IP address card renders correctly when enabled 1`] = ` </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -431,7 +431,7 @@ exports[`International IP address card renders correctly when enabled and checke </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -442,7 +442,7 @@ exports[`International IP address card renders correctly when enabled and checke data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -461,7 +461,7 @@ exports[`International IP address card renders correctly when enabled and checke </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -594,7 +594,7 @@ exports[`International IP address card renders like disabled when checked, but n </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -605,7 +605,7 @@ exports[`International IP address card renders like disabled when checked, but n data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -624,7 +624,7 @@ exports[`International IP address card renders like disabled when checked, but n </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > diff --git a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/order-items-threshold.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/order-items-threshold.test.tsx.snap index 000b975c4a2..05d4acfd78d 100644 --- a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/order-items-threshold.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/order-items-threshold.test.tsx.snap @@ -263,7 +263,7 @@ exports[`Order items threshold card renders correctly when enabled 1`] = ` <div> <br /> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -274,7 +274,7 @@ exports[`Order items threshold card renders correctly when enabled 1`] = ` data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -293,7 +293,7 @@ exports[`Order items threshold card renders correctly when enabled 1`] = ` </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -494,7 +494,7 @@ exports[`Order items threshold card renders correctly when enabled and checked 1 <div> <br /> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -505,7 +505,7 @@ exports[`Order items threshold card renders correctly when enabled and checked 1 data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -524,7 +524,7 @@ exports[`Order items threshold card renders correctly when enabled and checked 1 </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > diff --git a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/purchase-price-threshold.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/purchase-price-threshold.test.tsx.snap index c30d4bd6e8e..b702ef97cc5 100644 --- a/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/purchase-price-threshold.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/cards/test/__snapshots__/purchase-price-threshold.test.tsx.snap @@ -265,7 +265,7 @@ exports[`Purchase price threshold card renders correctly when enabled 1`] = ` <div> <br /> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -276,7 +276,7 @@ exports[`Purchase price threshold card renders correctly when enabled 1`] = ` data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -295,7 +295,7 @@ exports[`Purchase price threshold card renders correctly when enabled 1`] = ` </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -498,7 +498,7 @@ exports[`Purchase price threshold card renders correctly when enabled and checke <div> <br /> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -509,7 +509,7 @@ exports[`Purchase price threshold card renders correctly when enabled and checke data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -528,7 +528,7 @@ exports[`Purchase price threshold card renders correctly when enabled and checke </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > diff --git a/client/settings/fraud-protection/advanced-settings/rule-card-notice.tsx b/client/settings/fraud-protection/advanced-settings/rule-card-notice.tsx index 2c523993c28..30d2f6db72b 100644 --- a/client/settings/fraud-protection/advanced-settings/rule-card-notice.tsx +++ b/client/settings/fraud-protection/advanced-settings/rule-card-notice.tsx @@ -8,7 +8,7 @@ import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; * Internal dependencies */ import './../style.scss'; -import BannerNotice from 'wcpay/components/banner-notice'; +import InlineNotice from 'components/inline-notice'; import { TipIcon } from 'wcpay/icons'; const supportedTypes = [ 'error', 'warning', 'info' ] as const; @@ -31,7 +31,7 @@ const FraudProtectionRuleCardNotice: React.FC< FraudProtectionRuleCardNoticeProp const icon = 'info' === type ? <TipIcon /> : <NoticeOutlineIcon />; return ( - <BannerNotice + <InlineNotice status={ type } icon={ icon } className={ diff --git a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/allow-countries-notice.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/allow-countries-notice.test.tsx.snap index 40464a78f9b..af5c6da749b 100644 --- a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/allow-countries-notice.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/allow-countries-notice.test.tsx.snap @@ -31,7 +31,7 @@ Object { </div> <div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -42,7 +42,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -61,7 +61,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -77,7 +77,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -88,7 +88,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -107,7 +107,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -205,7 +205,7 @@ Object { </div> <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -216,7 +216,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -234,7 +234,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -253,7 +253,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -264,7 +264,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -282,7 +282,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -383,7 +383,7 @@ Object { </div> <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -394,7 +394,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -412,7 +412,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -431,7 +431,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -442,7 +442,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -460,7 +460,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -561,7 +561,7 @@ Object { </div> <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -572,7 +572,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -590,7 +590,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -609,7 +609,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -620,7 +620,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -638,7 +638,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -739,7 +739,7 @@ Object { </div> <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -750,7 +750,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -768,7 +768,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -787,7 +787,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -798,7 +798,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -816,7 +816,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > diff --git a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap index 94fbd23bc0b..b6af7f8078a 100644 --- a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap @@ -302,7 +302,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -313,7 +313,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -332,7 +332,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -726,7 +726,7 @@ Object { <div> <br /> <div - class="wcpay-banner-notice wcpay-banner-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" + class="wcpay-inline-notice wcpay-inline-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" > <div class="components-notice__content" @@ -737,7 +737,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -756,7 +756,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -942,7 +942,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -953,7 +953,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -972,14 +972,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more @@ -1300,7 +1300,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -1311,7 +1311,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -1330,7 +1330,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -1724,7 +1724,7 @@ Object { <div> <br /> <div - class="wcpay-banner-notice wcpay-banner-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" + class="wcpay-inline-notice wcpay-inline-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" > <div class="components-notice__content" @@ -1735,7 +1735,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -1754,7 +1754,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -1940,7 +1940,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -1951,7 +1951,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -1970,14 +1970,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more @@ -2363,7 +2363,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -2374,7 +2374,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -2393,7 +2393,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -2876,7 +2876,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -2887,7 +2887,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -2906,14 +2906,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more @@ -3218,7 +3218,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -3229,7 +3229,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -3248,7 +3248,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -3731,7 +3731,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -3742,7 +3742,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -3761,14 +3761,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more @@ -4120,7 +4120,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -4131,7 +4131,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -4150,7 +4150,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -4633,7 +4633,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -4644,7 +4644,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -4663,14 +4663,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more @@ -4938,7 +4938,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -4949,7 +4949,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -4968,7 +4968,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -5451,7 +5451,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -5462,7 +5462,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -5481,14 +5481,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more @@ -5856,7 +5856,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -5867,7 +5867,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -5886,7 +5886,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -6450,7 +6450,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -6461,7 +6461,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -6480,14 +6480,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more @@ -6774,7 +6774,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -6785,7 +6785,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -6804,7 +6804,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -7368,7 +7368,7 @@ Object { </p> </div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -7379,7 +7379,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -7398,14 +7398,14 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. <a data-link-type="external" - href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/#advanced-configuration" + href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/#advanced-configuration" target="_blank" > Learn more diff --git a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/rule-card-notice.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/rule-card-notice.test.tsx.snap index a95aa63bc28..eebe1c2ea74 100644 --- a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/rule-card-notice.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/rule-card-notice.test.tsx.snap @@ -31,7 +31,7 @@ Object { /> <div> <div - class="wcpay-banner-notice wcpay-banner-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" + class="wcpay-inline-notice wcpay-inline-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" > <div class="components-notice__content" @@ -42,7 +42,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -61,7 +61,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -77,7 +77,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" + class="wcpay-inline-notice wcpay-inline-error-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-error components-notice is-error" > <div class="components-notice__content" @@ -88,7 +88,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -107,7 +107,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -205,7 +205,7 @@ Object { </div> <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -216,7 +216,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -234,7 +234,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -250,7 +250,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" + class="wcpay-inline-notice wcpay-inline-info-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-info components-notice is-info" > <div class="components-notice__content" @@ -261,7 +261,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-info-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -279,7 +279,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-info-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -377,7 +377,7 @@ Object { </div> <div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -388,7 +388,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -407,7 +407,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -423,7 +423,7 @@ Object { </body>, "container": <div> <div - class="wcpay-banner-notice wcpay-banner-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" + class="wcpay-inline-notice wcpay-inline-warning-notice fraud-protection-rule-card-notice fraud-protection-rule-card-notice-warning components-notice is-warning" > <div class="components-notice__content" @@ -434,7 +434,7 @@ Object { data-wp-component="Flex" > <div - class="components-flex-item wcpay-banner-notice__icon wcpay-banner-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-warning-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > @@ -453,7 +453,7 @@ Object { </svg> </div> <div - class="components-flex-item wcpay-banner-notice__content wcpay-banner-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + class="components-flex-item wcpay-inline-notice__content wcpay-inline-warning-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" data-wp-c16t="true" data-wp-component="FlexItem" > diff --git a/client/settings/fraud-protection/components/protection-levels/index.tsx b/client/settings/fraud-protection/components/protection-levels/index.tsx index 0eb2e86f74a..b8597a68832 100644 --- a/client/settings/fraud-protection/components/protection-levels/index.tsx +++ b/client/settings/fraud-protection/components/protection-levels/index.tsx @@ -17,7 +17,7 @@ import { import { FraudProtectionHelpText, BasicFraudProtectionModal } from '../index'; import { getAdminUrl } from 'wcpay/utils'; import { ProtectionLevel } from '../../advanced-settings/constants'; -import InlineNotice from '../../../../components/inline-notice'; +import InlineNotice from 'components/inline-notice'; import wcpayTracks from 'tracks'; import { CurrentProtectionLevelHook } from '../../interfaces'; @@ -57,6 +57,7 @@ const ProtectionLevels: React.FC = () => { <> { 'error' === advancedFraudProtectionSettings && ( <InlineNotice + icon status="error" isDismissible={ false } className={ '' } diff --git a/client/settings/fraud-protection/components/protection-levels/test/__snapshots__/index.test.tsx.snap b/client/settings/fraud-protection/components/protection-levels/test/__snapshots__/index.test.tsx.snap index 1cdc61edcac..9fc6a497771 100644 --- a/client/settings/fraud-protection/components/protection-levels/test/__snapshots__/index.test.tsx.snap +++ b/client/settings/fraud-protection/components/protection-levels/test/__snapshots__/index.test.tsx.snap @@ -89,12 +89,43 @@ exports[`ProtectionLevels renders 1`] = ` exports[`ProtectionLevels renders an error message when settings can not be fetched from the server 1`] = ` <div> <div - class="wcpay-inline-notice components-notice is-error" + class="wcpay-inline-notice wcpay-inline-error-notice components-notice is-error" > <div class="components-notice__content" > - There was an error retrieving your fraud protection settings. Please refresh the page to try again. + <div + class="components-flex css-bmzg3m-View-Flex-sx-Base-sx-Items-ItemsRow em57xhy0" + data-wp-c16t="true" + data-wp-component="Flex" + > + <div + class="components-flex-item wcpay-inline-notice__icon wcpay-inline-error-notice__icon css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + <svg + class="gridicon gridicons-notice-outline" + height="24" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 4c4.411 0 8 3.589 8 8s-3.589 8-8 8-8-3.589-8-8 3.589-8 8-8m0-2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 13h-2v2h2v-2zm-2-2h2l.5-6h-3l.5 6z" + /> + </g> + </svg> + </div> + <div + class="components-flex-item wcpay-inline-notice__content wcpay-inline-error-notice__content css-mw3lhz-View-Item-sx-Base em57xhy0" + data-wp-c16t="true" + data-wp-component="FlexItem" + > + There was an error retrieving your fraud protection settings. Please refresh the page to try again. + </div> + </div> <div class="components-notice__actions" /> diff --git a/client/settings/fraud-protection/style.scss b/client/settings/fraud-protection/style.scss index 614267a6050..14be0e0899b 100644 --- a/client/settings/fraud-protection/style.scss +++ b/client/settings/fraud-protection/style.scss @@ -249,10 +249,6 @@ border-bottom: 1px solid #e0e0e0; border-top: 0; } - .wcpay-banner-notice.fraud-protection-rule-card-notice { - margin-left: 0; - margin-right: 0; - } } &__help-icon { diff --git a/client/settings/general-settings/index.js b/client/settings/general-settings/index.js index 08ad4cf96d0..e9f554620ea 100644 --- a/client/settings/general-settings/index.js +++ b/client/settings/general-settings/index.js @@ -64,7 +64,7 @@ const GeneralSettings = () => { target="_blank" rel="noreferrer" /* eslint-disable-next-line max-len */ - href="https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/#test-cards" + href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards" /> ), learnMoreLink: ( @@ -72,7 +72,7 @@ const GeneralSettings = () => { <a target="_blank" rel="noreferrer" - href="https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/" + href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/" /> ), }, diff --git a/client/settings/settings-manager/index.js b/client/settings/settings-manager/index.js index 7b61ed5ed51..9f8c05172d2 100644 --- a/client/settings/settings-manager/index.js +++ b/client/settings/settings-manager/index.js @@ -52,7 +52,7 @@ const ExpressCheckoutDescription = () => ( 'woocommerce-payments' ) } </p> - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/settings-guide/#express-checkouts"> + <ExternalLink href="https://woocommerce.com/document/woopayments/settings-guide/#express-checkouts"> { __( 'Learn more', 'woocommerce-payments' ) } </ExternalLink> </> @@ -83,7 +83,7 @@ const TransactionsDescription = () => ( 'woocommerce-payments' ) } </p> - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/"> + <ExternalLink href="https://woocommerce.com/document/woopayments/"> { __( 'View our documentation', 'woocommerce-payments' ) } </ExternalLink> </> @@ -104,7 +104,7 @@ const DepositsDescription = () => { depositDelayDays ) } </p> - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/deposits/deposit-schedule/"> + <ExternalLink href="https://woocommerce.com/document/woopayments/deposits/deposit-schedule/"> { __( 'Learn more about pending schedules', 'woocommerce-payments' @@ -124,7 +124,7 @@ const FraudProtectionDescription = () => { 'woocommerce-payments' ) } </p> - <ExternalLink href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/"> + <ExternalLink href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/"> { __( 'Learn more about risk filtering', 'woocommerce-payments' @@ -134,6 +134,23 @@ const FraudProtectionDescription = () => { ); }; +const AdvancedDescription = () => { + return ( + <> + <h2>{ __( 'Advanced settings', 'woocommerce-payments' ) }</h2> + <p> + { __( + 'More options for specific payment needs.', + 'woocommerce-payments' + ) } + </p> + <ExternalLink href="https://woocommerce.com/document/woopayments/settings-guide/#advanced-settings"> + { __( 'View our documentation', 'woocommerce-payments' ) } + </ExternalLink> + </> + ); +}; + const SettingsManager = () => { const { featureFlags: { @@ -252,7 +269,16 @@ const SettingsManager = () => { </ErrorBoundary> </LoadableSettingsSection> </SettingsSection> - <AdvancedSettings /> + <SettingsSection + description={ AdvancedDescription } + id="advanced-settings" + > + <LoadableSettingsSection numLines={ 20 }> + <ErrorBoundary> + <AdvancedSettings /> + </ErrorBoundary> + </LoadableSettingsSection> + </SettingsSection> <SaveSettingsSection disabled={ ! isTransactionInputsValid } /> </SettingsLayout> ); diff --git a/client/settings/survey-modal/index.js b/client/settings/survey-modal/index.js index 4b9d673ad73..45bfe42194a 100644 --- a/client/settings/survey-modal/index.js +++ b/client/settings/survey-modal/index.js @@ -14,8 +14,8 @@ import ConfirmationModal from 'components/confirmation-modal'; import useIsUpeEnabled from 'settings/wcpay-upe-toggle/hook'; import { wcPaySurveys } from './questions'; import WcPaySurveyContext from './context'; -import InlineNotice from '../../components/inline-notice'; -import { LoadableBlock } from '../../components/loadable'; +import InlineNotice from 'components/inline-notice'; +import { LoadableBlock } from 'components/loadable'; const SurveyModalBody = ( { options, surveyQuestion } ) => { const [ isUpeEnabled ] = useIsUpeEnabled(); @@ -26,7 +26,7 @@ const SurveyModalBody = ( { options, surveyQuestion } ) => { return ( <> { ! isUpeEnabled && ( - <InlineNotice status="success" isDismissible={ false }> + <InlineNotice icon status="success" isDismissible={ false }> { __( "You've disabled the new payments experience in your store.", 'woocommerce-payments' diff --git a/client/settings/transactions/index.js b/client/settings/transactions/index.js index eb929c8e2a9..e4b11b66b21 100644 --- a/client/settings/transactions/index.js +++ b/client/settings/transactions/index.js @@ -15,6 +15,8 @@ import { import CardBody from '../card-body'; import { useAccountStatementDescriptor, + useAccountStatementDescriptorKanji, + useAccountStatementDescriptorKana, useGetSavingError, useSavedCards, } from '../../data'; @@ -23,8 +25,12 @@ import ManualCaptureControl from 'wcpay/settings/transactions/manual-capture-con import SupportPhoneInput from 'wcpay/settings/support-phone-input'; import SupportEmailInput from 'wcpay/settings/support-email-input'; import React, { useEffect, useState } from 'react'; +import { select } from '@wordpress/data'; +import { STORE_NAME } from 'wcpay/data/constants'; const ACCOUNT_STATEMENT_MAX_LENGTH = 22; +const ACCOUNT_STATEMENT_MAX_LENGTH_KANJI = 17; +const ACCOUNT_STATEMENT_MAX_LENGTH_KANA = 22; const Transactions = ( { setTransactionInputsValid } ) => { const [ isSavedCardsEnabled, setIsSavedCardsEnabled ] = useSavedCards(); @@ -32,11 +38,20 @@ const Transactions = ( { setTransactionInputsValid } ) => { accountStatementDescriptor, setAccountStatementDescriptor, ] = useAccountStatementDescriptor(); + const [ + accountStatementDescriptorKanji, + setAccountStatementDescriptorKanji, + ] = useAccountStatementDescriptorKanji(); + const [ + accountStatementDescriptorKana, + setAccountStatementDescriptorKana, + ] = useAccountStatementDescriptorKana(); const customerBankStatementErrorMessage = useGetSavingError()?.data?.details ?.account_statement_descriptor?.message; const [ isEmailInputValid, setEmailInputValid ] = useState( true ); const [ isPhoneInputValid, setPhoneInputValid ] = useState( true ); + const settings = select( STORE_NAME ).getSettings(); useEffect( () => { if ( setTransactionInputsValid ) { @@ -64,9 +79,15 @@ const Transactions = ( { setTransactionInputsValid } ) => { ) } /> <ManualCaptureControl></ManualCaptureControl> - <h4>{ __( 'Customer support', 'woocommerce-payments' ) }</h4> + <h4>{ __( 'Customer statements', 'woocommerce-payments' ) }</h4> + <p className="transactions-customer-details"> + { __( + "Edit the way your store name appears on your customers' bank statements.", + 'woocommerce-payments' + ) } + </p> - <div className="transactions__customer-support"> + <div className="transactions__customer-statements"> { customerBankStatementErrorMessage && ( <Notice status="error" isDismissible={ false }> <span @@ -78,10 +99,13 @@ const Transactions = ( { setTransactionInputsValid } ) => { ) } <TextControl className="transactions__account-statement-input" - help={ __( - "Edit the way your store name appears on your customers' bank statements.", - 'woocommerce-payments' - ) } + help={ + settings.account_country === 'JP' && + __( + 'Use only latin characters.', + 'woocommerce-payments' + ) + } label={ __( 'Customer bank statement', 'woocommerce-payments' @@ -94,7 +118,79 @@ const Transactions = ( { setTransactionInputsValid } ) => { <span className="input-help-text" aria-hidden="true"> { `${ accountStatementDescriptor.length } / ${ ACCOUNT_STATEMENT_MAX_LENGTH }` } </span> + { settings.account_country === 'JP' && ( + <> + <div className="transactions__customer-support"> + <TextControl + className="transactions__account-statement-input" + help={ __( + 'Use only kanji characters.', + 'woocommerce-payments' + ) } + label={ __( + 'Customer bank statement (kanji)', + 'woocommerce-payments' + ) } + value={ accountStatementDescriptorKanji } + onChange={ + setAccountStatementDescriptorKanji + } + maxLength={ + ACCOUNT_STATEMENT_MAX_LENGTH_KANJI + } + data-testid={ + 'store-name-bank-statement-kanji' + } + /> + <span + className="input-help-text" + aria-hidden="true" + > + { `${ accountStatementDescriptorKanji.length } / ${ ACCOUNT_STATEMENT_MAX_LENGTH_KANJI }` } + </span> + </div> + <div className="transactions__customer-support"> + <TextControl + className="transactions__account-statement-input" + help={ __( + 'Use only kana characters.', + 'woocommerce-payments' + ) } + label={ __( + 'Customer bank statement (kana)', + 'woocommerce-payments' + ) } + value={ accountStatementDescriptorKana } + onChange={ + setAccountStatementDescriptorKana + } + maxLength={ + ACCOUNT_STATEMENT_MAX_LENGTH_KANA + } + data-testid={ + 'store-name-bank-statement-kana' + } + /> + <span + className="input-help-text" + aria-hidden="true" + > + { `${ accountStatementDescriptorKana.length } / ${ ACCOUNT_STATEMENT_MAX_LENGTH_KANA }` } + </span> + </div> + </> + ) } + </div> + + <h4>{ __( 'Customer support', 'woocommerce-payments' ) }</h4> + <p className="transactions-customer-details"> + { __( + 'Provide contact information where customers can reach you for support.', + 'woocommerce-payments' + ) } + </p> + <div className="transactions__customer-support"> <SupportEmailInput setInputVallid={ setEmailInputValid } /> <SupportPhoneInput setInputVallid={ setPhoneInputValid } /> </div> diff --git a/client/settings/transactions/style.scss b/client/settings/transactions/style.scss index 8a33c2b1381..5bd41a858d7 100644 --- a/client/settings/transactions/style.scss +++ b/client/settings/transactions/style.scss @@ -5,7 +5,8 @@ margin-bottom: 1em; } - &__customer-support { + &__customer-support, + &__customer-statements { max-width: 500px; position: relative; @@ -27,3 +28,9 @@ } } } + +.transactions-customer-details { + font-size: 12px; + font-style: normal; + color: #757575; +} diff --git a/client/settings/transactions/test/index.test.js b/client/settings/transactions/test/index.test.js index d8ab3fb6281..c5a3807e51f 100644 --- a/client/settings/transactions/test/index.test.js +++ b/client/settings/transactions/test/index.test.js @@ -10,15 +10,31 @@ import Transactions from '..'; import { useGetSavingError, useAccountStatementDescriptor, + useAccountStatementDescriptorKanji, + useAccountStatementDescriptorKana, useAccountBusinessSupportEmail, useAccountBusinessSupportPhone, useManualCapture, useSavedCards, useCardPresentEligible, } from '../../../data'; +import { select } from '@wordpress/data'; + +jest.mock( '@wordpress/data', () => ( { + select: jest.fn(), +} ) ); +const settingsMock = { + account_country: 'US', +}; + +select.mockReturnValue( { + getSettings: () => settingsMock, +} ); jest.mock( 'wcpay/data', () => ( { useAccountStatementDescriptor: jest.fn(), + useAccountStatementDescriptorKanji: jest.fn(), + useAccountStatementDescriptorKana: jest.fn(), useAccountBusinessSupportEmail: jest.fn(), useAccountBusinessSupportPhone: jest.fn(), useManualCapture: jest.fn(), @@ -30,6 +46,8 @@ jest.mock( 'wcpay/data', () => ( { describe( 'Settings - Transactions', () => { beforeEach( () => { useAccountStatementDescriptor.mockReturnValue( [ '', jest.fn() ] ); + useAccountStatementDescriptorKanji.mockReturnValue( [ '', jest.fn() ] ); + useAccountStatementDescriptorKana.mockReturnValue( [ '', jest.fn() ] ); useAccountBusinessSupportEmail.mockReturnValue( [ 'test@test.com', jest.fn(), @@ -120,4 +138,25 @@ describe( 'Settings - Transactions', () => { ).toBeInTheDocument(); expect( screen.getByLabelText( 'Support email' ) ).toBeInTheDocument(); } ); + + it( 'display customer bank statements for JP', async () => { + const settingsMockCountryJP = { + account_country: 'JP', + }; + + select.mockReturnValue( { + getSettings: () => settingsMockCountryJP, + } ); + render( <Transactions /> ); + + expect( + await screen.findByText( 'Use only latin characters.' ) + ).toBeInTheDocument(); + expect( + await screen.findByText( 'Use only kanji characters.' ) + ).toBeInTheDocument(); + expect( + await screen.findByText( 'Use only kana characters.' ) + ).toBeInTheDocument(); + } ); } ); diff --git a/client/settings/wcpay-settings-context.js b/client/settings/wcpay-settings-context.js index df2fc4e1d06..0966d6e9c47 100644 --- a/client/settings/wcpay-settings-context.js +++ b/client/settings/wcpay-settings-context.js @@ -10,6 +10,7 @@ const WCPaySettingsContext = createContext( { featureFlags: { isAuthAndCaptureEnabled: false, isDisputeOnTransactionPageEnabled: false, + woopay: false, }, } ); diff --git a/client/stylesheets/abstracts/_colors.scss b/client/stylesheets/abstracts/_colors.scss index d8535761423..fe2338dc439 100644 --- a/client/stylesheets/abstracts/_colors.scss +++ b/client/stylesheets/abstracts/_colors.scss @@ -66,3 +66,12 @@ $wp-green-70: #005c12; $wp-green-80: #00450c; $wp-green-90: #003008; $wp-green-100: #001c05; + +// Missing from dependencies +$gutenberg-blue: #007cba; + +// Accent color +$components-color-accent: var( + --wp-components-color-accent, + var( --wp-admin-theme-color, $gutenberg-blue ) +); diff --git a/client/stylesheets/abstracts/_variables.scss b/client/stylesheets/abstracts/_variables.scss index 8a48d1224ab..4766b1844fd 100644 --- a/client/stylesheets/abstracts/_variables.scss +++ b/client/stylesheets/abstracts/_variables.scss @@ -9,6 +9,3 @@ $gap-smallest: 4px; // Modals $modal-max-width: 500px; - -// Colors (missing from dependencies) -$gutenberg-blue: #007cba; diff --git a/client/transactions/list/converted-amount.tsx b/client/transactions/list/converted-amount.tsx index fce2497957b..6a74b01a25a 100644 --- a/client/transactions/list/converted-amount.tsx +++ b/client/transactions/list/converted-amount.tsx @@ -5,42 +5,54 @@ */ import React from 'react'; import { __, sprintf } from '@wordpress/i18n'; -import { Tooltip } from '@wordpress/components'; +import { Tooltip as FallbackTooltip } from '@wordpress/components'; import SyncIcon from 'gridicons/dist/sync'; +import classNames from 'classnames'; /** * Internal dependencies */ import { formatExplicitCurrency } from 'utils/currency'; +declare const window: any; + interface ConversionIndicatorProps { amount: number; currency: string; baseCurrency: string; + fallback?: boolean; } const ConversionIndicator = ( { amount, currency, + fallback, baseCurrency, -}: ConversionIndicatorProps ): React.ReactElement => ( - <Tooltip - text={ sprintf( - /* translators: %s is a monetary amount */ - __( 'Converted from %s', 'woocommerce-payments' ), - formatExplicitCurrency( amount, currency, false, baseCurrency ) - ) } - position="bottom center" - > - <span - className="conversion-indicator" - data-testid="conversion-indicator" - style={ { height: '18px', width: '18px' } } +}: ConversionIndicatorProps ): React.ReactElement => { + // If it's available, use the component from WP, not the one within WCPay, as WP's uses an updated component. + const Tooltip = ! fallback + ? window?.wp?.components?.Tooltip + : FallbackTooltip; + + return ( + <Tooltip + text={ sprintf( + /* translators: %s is a monetary amount */ + __( 'Converted from %s', 'woocommerce-payments' ), + formatExplicitCurrency( amount, currency, false, baseCurrency ) + ) } + position="bottom center" > - <SyncIcon size={ 18 } /> - </span> - </Tooltip> -); + <span + className="conversion-indicator" + data-testid="conversion-indicator" + style={ { height: '18px', width: '18px' } } + > + <SyncIcon size={ 18 } /> + </span> + </Tooltip> + ); +}; interface ConvertedAmountProps { amount: number; @@ -62,11 +74,19 @@ const ConvertedAmount = ( { return <>{ formattedCurrency }</>; } + const isUpdatedTooltipAvailable = !! window?.wp?.components?.Tooltip; + return ( - <div className="converted-amount"> + <div + className={ classNames( + 'converted-amount', + ! isUpdatedTooltipAvailable && 'converted-amount--fallback' + ) } + > <ConversionIndicator amount={ fromAmount } currency={ fromCurrency } + fallback={ ! isUpdatedTooltipAvailable } baseCurrency={ currency } /> { formattedCurrency } diff --git a/client/transactions/list/style.scss b/client/transactions/list/style.scss index f5ee0405cc3..e33b7755e91 100644 --- a/client/transactions/list/style.scss +++ b/client/transactions/list/style.scss @@ -16,9 +16,11 @@ $space-header-item: 12px; fill: $studio-gray-30; } - .components-popover__content { - position: relative; - top: -( $gap * 2 ); // Positioning the tooltip in a higher position to avoid having it cropped by the bottom of the table + &--fallback { + .components-popover__content { + position: relative; + top: -( $gap * 2 ); // Positioning the tooltip in a higher position to avoid having it cropped by the bottom of the table + } } } diff --git a/client/transactions/list/test/__snapshots__/converted-amount.tsx.snap b/client/transactions/list/test/__snapshots__/converted-amount.tsx.snap index 522c18bb7bf..7c5c9e2fe60 100644 --- a/client/transactions/list/test/__snapshots__/converted-amount.tsx.snap +++ b/client/transactions/list/test/__snapshots__/converted-amount.tsx.snap @@ -3,7 +3,7 @@ exports[`ConvertedAmount renders an amount with conversion icon and tooltip 1`] = ` <div> <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" @@ -32,7 +32,7 @@ exports[`ConvertedAmount renders an amount with conversion icon and tooltip 1`] exports[`ConvertedAmount renders an amount with conversion icon and tooltip 2`] = ` <div> <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" diff --git a/client/transactions/list/test/__snapshots__/index.tsx.snap b/client/transactions/list/test/__snapshots__/index.tsx.snap index fc7cbac7bb3..8f9669c6be4 100644 --- a/client/transactions/list/test/__snapshots__/index.tsx.snap +++ b/client/transactions/list/test/__snapshots__/index.tsx.snap @@ -660,7 +660,7 @@ exports[`Transactions list renders correctly when can filter by several currenci tabindex="-1" > <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" @@ -1593,7 +1593,7 @@ exports[`Transactions list renders correctly when filtered by currency 1`] = ` tabindex="-1" > <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" @@ -2378,7 +2378,7 @@ exports[`Transactions list renders correctly when filtered by deposit 1`] = ` tabindex="-1" > <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" @@ -3226,7 +3226,7 @@ exports[`Transactions list subscription column renders correctly 1`] = ` tabindex="-1" > <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" @@ -4207,7 +4207,7 @@ exports[`Transactions list when not filtered by deposit renders correctly 1`] = tabindex="-1" > <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" @@ -5182,7 +5182,7 @@ exports[`Transactions list when not filtered by deposit renders table summary on tabindex="-1" > <div - class="converted-amount" + class="converted-amount converted-amount--fallback" > <span class="conversion-indicator" diff --git a/client/types/disputes.d.ts b/client/types/disputes.d.ts index 2a5c5e45a76..cea3ea886c9 100644 --- a/client/types/disputes.d.ts +++ b/client/types/disputes.d.ts @@ -11,11 +11,42 @@ interface Evidence { } interface EvidenceDetails { + /** + * Whether evidence has been staged for this dispute. + */ has_evidence: boolean; + /** + * Date by which evidence must be submitted in order to successfully challenge dispute. + */ due_by: number; + /** + * Whether the last evidence submission was submitted past the due date. Defaults to false if no evidence submissions have occurred. If true, then delivery of the latest evidence is not guaranteed. + */ + past_due: boolean; + /** + * The number of times evidence has been submitted. Typically, the merchant may only submit evidence once. + */ submission_count: number; } +/** + * See https://stripe.com/docs/api/disputes/object#dispute_object-issuer_evidence + */ +interface IssuerEvidence { + /** + * Type of issuer evidence supplied. + */ + evidence_type: 'retrieval' | 'chargeback' | 'response'; + /** + * List of up to 5 (ID of a file upload) File-based issuer evidence. + */ + file_evidence: string[]; + /** + * Free-form, text-based issuer evidence. + */ + text_evidence: string | null; +} + export type DisputeReason = | 'bank_cannot_process' | 'check_returned' @@ -49,9 +80,10 @@ export interface Dispute { metadata: Record< string, any >; order: null | OrderDetails; evidence: Evidence; + issuer_evidence: IssuerEvidence | null; fileSize?: Record< string, number >; reason: DisputeReason; - charge: Charge; + charge: Charge | string; amount: number; currency: string; created: number; diff --git a/client/utils/account-fees.tsx b/client/utils/account-fees.tsx index 3f1175f44d2..f7cdd94a149 100644 --- a/client/utils/account-fees.tsx +++ b/client/utils/account-fees.tsx @@ -18,9 +18,9 @@ import { PaymentMethod } from 'wcpay/types/payment-methods'; import { createInterpolateElement } from '@wordpress/element'; const countryFeeStripeDocsBaseLink = - 'https://woocommerce.com/document/woocommerce-payments/fees-and-debits/fees/#'; + 'https://woocommerce.com/document/woopayments/fees-and-debits/fees/#'; const countryFeeStripeDocsBaseLinkNoCountry = - 'https://woocommerce.com/document/woocommerce-payments/fees-and-debits/fees/'; + 'https://woocommerce.com/document/woopayments/fees-and-debits/fees/'; const countryFeeStripeDocsSectionNumbers: Record< string, string > = { AE: 'united-arab-emirates', AU: 'australia', diff --git a/client/utils/test/__snapshots__/account-fees.tsx.snap b/client/utils/test/__snapshots__/account-fees.tsx.snap index d219db01904..ab1e34eee08 100644 --- a/client/utils/test/__snapshots__/account-fees.tsx.snap +++ b/client/utils/test/__snapshots__/account-fees.tsx.snap @@ -44,7 +44,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base > <span> <a - href="https://woocommerce.com/document/woocommerce-payments/fees-and-debits/fees/#united-states" + href="https://woocommerce.com/document/woopayments/fees-and-debits/fees/#united-states" rel="noreferrer" target="_blank" > @@ -101,7 +101,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base > <span> <a - href="https://woocommerce.com/document/woocommerce-payments/fees-and-debits/fees/#united-states" + href="https://woocommerce.com/document/woopayments/fees-and-debits/fees/#united-states" rel="noreferrer" target="_blank" > @@ -158,7 +158,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays custo > <span> <a - href="https://woocommerce.com/document/woocommerce-payments/fees-and-debits/fees/#united-states" + href="https://woocommerce.com/document/woopayments/fees-and-debits/fees/#united-states" rel="noreferrer" target="_blank" > diff --git a/composer.json b/composer.json index 044e88faa03..668bf0f26a7 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "automattic/jetpack-autoloader": "2.11.18", "automattic/jetpack-identity-crisis": "0.8.43", "automattic/jetpack-sync": "1.47.7", - "woocommerce/subscriptions-core": "6.0.0", + "woocommerce/subscriptions-core": "6.2.0", "psr/container": "^1.1" }, "require-dev": { @@ -91,8 +91,8 @@ "WCPay\\Vendor\\": "lib/packages", "WCPay\\": "src" }, - "files": [ - "src/wcpay-get-container.php" + "files": [ + "src/wcpay-get-container.php" ] }, "repositories": [ diff --git a/composer.lock b/composer.lock index fb2aec0a2d2..a5963e45fdb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e5d6dbd579dee11681fa7a39a11506f1", + "content-hash": "86b5f217949dc6931b79653be4a6dca8", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -988,16 +988,16 @@ }, { "name": "woocommerce/subscriptions-core", - "version": "6.0.0", + "version": "6.2.0", "source": { "type": "git", "url": "https://github.com/Automattic/woocommerce-subscriptions-core.git", - "reference": "b48c46a6a08b73d8afc7a0e686173c5b5c55ecbb" + "reference": "47cfe92d60239d1b8b12a5f640a3772b0e4e1272" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/woocommerce-subscriptions-core/zipball/b48c46a6a08b73d8afc7a0e686173c5b5c55ecbb", - "reference": "b48c46a6a08b73d8afc7a0e686173c5b5c55ecbb", + "url": "https://api.github.com/repos/Automattic/woocommerce-subscriptions-core/zipball/47cfe92d60239d1b8b12a5f640a3772b0e4e1272", + "reference": "47cfe92d60239d1b8b12a5f640a3772b0e4e1272", "shasum": "" }, "require": { @@ -1038,10 +1038,10 @@ "description": "Sell products and services with recurring payments in your WooCommerce Store.", "homepage": "https://github.com/Automattic/woocommerce-subscriptions-core", "support": { - "source": "https://github.com/Automattic/woocommerce-subscriptions-core/tree/6.0.0", + "source": "https://github.com/Automattic/woocommerce-subscriptions-core/tree/6.2.0", "issues": "https://github.com/Automattic/woocommerce-subscriptions-core/issues" }, - "time": "2023-07-18T06:28:51+00:00" + "time": "2023-08-10T23:43:48+00:00" } ], "packages-dev": [ diff --git a/i18n/currency-info.php b/i18n/currency-info.php index aa30c275923..6cfe8d46ed6 100644 --- a/i18n/currency-info.php +++ b/i18n/currency-info.php @@ -127,8 +127,8 @@ return [ 'AED' => [ - 'ar_AE' => $global_formats['rs_comma_dot_rtl'], - 'default' => $global_formats['rs_comma_dot_rtl'], + 'ar_AE' => $global_formats['rs_dot_comma_rtl'], + 'default' => $global_formats['rs_dot_comma_rtl'], ], 'AFN' => [ 'fa_AF' => $global_formats['ls_comma_dot_rtl'], @@ -723,8 +723,8 @@ 'rw_RW' => $global_formats['ls_comma_dot_ltr'], ], 'SAR' => [ - 'ar_SA' => $global_formats['rs_comma_dot_rtl'], - 'default' => $global_formats['rs_comma_dot_rtl'], + 'ar_SA' => $global_formats['rs_dot_comma_rtl'], + 'default' => $global_formats['rs_dot_comma_rtl'], ], 'SBD' => [ 'en_SB' => $global_formats['lx_dot_comma_ltr'], diff --git a/i18n/locale-info.php b/i18n/locale-info.php index 0c8f4150da7..59c234ae970 100644 --- a/i18n/locale-info.php +++ b/i18n/locale-info.php @@ -30,8 +30,8 @@ 'AE' => [ 'currency_code' => 'AED', 'currency_pos' => 'right_space', - 'thousand_sep' => '.', - 'decimal_sep' => ',', + 'thousand_sep' => ',', + 'decimal_sep' => '.', 'num_decimals' => 2, 'weight_unit' => 'kg', 'dimension_unit' => 'cm', @@ -3070,8 +3070,8 @@ 'SA' => [ 'currency_code' => 'SAR', 'currency_pos' => 'right_space', - 'thousand_sep' => '.', - 'decimal_sep' => ',', + 'thousand_sep' => ',', + 'decimal_sep' => '.', 'num_decimals' => 2, 'weight_unit' => 'kg', 'dimension_unit' => 'cm', diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index 6cb36ed67d7..42be3812564 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -145,8 +145,8 @@ public function __construct( add_action( 'admin_menu', [ $this, 'add_payments_menu' ], 0 ); add_action( 'admin_init', [ $this, 'maybe_redirect_to_onboarding' ], 11 ); // Run this after the WC setup wizard and onboarding redirection logic. add_action( 'admin_enqueue_scripts', [ $this, 'maybe_redirect_overview_to_connect' ], 1 ); // Run this late (after `admin_init`) but before any scripts are actually enqueued. - add_action( 'admin_enqueue_scripts', [ $this, 'register_payments_scripts' ] ); - add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_payments_scripts' ], 12 ); + add_action( 'admin_enqueue_scripts', [ $this, 'register_payments_scripts' ], 9 ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_payments_scripts' ], 9 ); add_action( 'woocommerce_admin_field_payment_gateways', [ $this, 'payment_gateways_container' ] ); add_action( 'woocommerce_admin_order_totals_after_total', [ $this, 'show_woopay_payment_method_name_admin' ] ); add_action( 'woocommerce_admin_order_totals_after_total', [ $this, 'display_wcpay_transaction_fee' ] ); @@ -772,6 +772,13 @@ private function get_js_settings(): array { Logger::log( sprintf( 'WCPay JS settings: Could not determine if WCPay should be in test mode! Message: %s', $e->getMessage() ), 'warning' ); } + $dev_mode = false; + try { + $dev_mode = WC_Payments::mode()->is_dev(); + } catch ( Exception $e ) { + Logger::log( sprintf( 'WCPay JS settings: Could not determine if WCPay should be in dev mode! Message: %s', $e->getMessage() ), 'warning' ); + } + $connect_url = WC_Payments_Account::get_connect_url(); $connect_incentive = $this->incentives_service->get_cached_connect_incentive(); // If we have an incentive ID, attach it to the connect URL. @@ -787,6 +794,7 @@ private function get_js_settings(): array { 'availableStates' => WC()->countries->get_states(), ], 'connectIncentive' => $connect_incentive, + 'devMode' => $dev_mode, 'testMode' => $test_mode, 'onboardingTestMode' => WC_Payments_Onboarding_Service::is_test_mode_enabled(), // Set this flag for use in the front-end to alter messages and notices if on-boarding has been disabled. @@ -829,17 +837,45 @@ private function get_js_settings(): array { 'fraudProtection' => [ 'isWelcomeTourDismissed' => WC_Payments_Features::is_fraud_protection_welcome_tour_dismissed(), ], + 'enabledPaymentMethods' => $this->get_enabled_payment_method_ids(), 'progressiveOnboarding' => $this->account->get_progressive_onboarding_details(), 'accountDefaultCurrency' => $this->account->get_account_default_currency(), 'frtDiscoverBannerSettings' => get_option( 'wcpay_frt_discover_banner_settings', '' ), 'storeCurrency' => get_option( 'woocommerce_currency' ), 'isBnplAffirmAfterpayEnabled' => WC_Payments_Features::is_bnpl_affirm_afterpay_enabled(), 'isWooPayStoreCountryAvailable' => WooPay_Utilities::is_store_country_available(), + 'isStripeBillingEnabled' => WC_Payments_Features::is_stripe_billing_enabled(), + 'isStripeBillingEligible' => WC_Payments_Features::is_stripe_billing_eligible(), ]; return apply_filters( 'wcpay_js_settings', $this->wcpay_js_settings ); } + /** + * Helper function to retrieve enabled UPE payment methods. + * + * TODO: This is duplicating code located in the settings container, we should refactor so that + * this is stored in a centralised place and can be retrieved from there. + * + * @return array + */ + private function get_enabled_payment_method_ids(): array { + $available_upe_payment_methods = $this->wcpay_gateway->get_upe_available_payment_methods(); + /** + * It might be possible that enabled payment methods settings have an invalid state. As an example, + * if an account is switched to a new country and earlier country had PM's that are no longer valid; or if the PM is not available anymore. + * To keep saving settings working, we are ensuring the enabled payment methods are yet available. + */ + $enabled_payment_methods = array_values( + array_intersect( + $this->wcpay_gateway->get_upe_enabled_payment_method_ids(), + $available_upe_payment_methods + ) + ); + + return $enabled_payment_methods; + } + /** * Creates an array of features enabled only when external dependencies are of certain versions. * @@ -918,9 +954,12 @@ public function add_menu_notification_badge() { return; } + $badge = self::MENU_NOTIFICATION_BADGE; foreach ( $menu as $index => $menu_item ) { - if ( 'wc-admin&path=/payments/connect' === $menu_item[2] ) { - $menu[ $index ][0] .= self::MENU_NOTIFICATION_BADGE; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + if ( false === strpos( $menu_item[0], $badge ) && ( 'wc-admin&path=/payments/connect' === $menu_item[2] ) ) { + $menu[ $index ][0] .= $badge; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + // One menu item with a badge is more than enough. break; } } diff --git a/includes/admin/class-wc-rest-payments-files-controller.php b/includes/admin/class-wc-rest-payments-files-controller.php index e84ba70c074..db41002e1df 100644 --- a/includes/admin/class-wc-rest-payments-files-controller.php +++ b/includes/admin/class-wc-rest-payments-files-controller.php @@ -33,6 +33,26 @@ public function register_routes() { ] ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P<file_id>\w+)/details', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_file_detail' ], + 'permission_callback' => [ $this, 'check_permission' ], + ] + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P<file_id>\w+)/content', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_file_content' ], + 'permission_callback' => [ $this, 'check_permission' ], + ] + ); + register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<file_id>\w+)', @@ -42,6 +62,7 @@ public function register_routes() { 'permission_callback' => [], ] ); + } /** @@ -116,6 +137,53 @@ function ( bool $served, WP_HTTP_Response $response ) : bool { } + /** + * Retrieve file details via the API. + * + * Example response: + * { + * "id": "file_1Np1S5J5cIRIG92xknlr0iND", + * "object": "file", + * "created": 1694405421, + * "expires_at": 1717733421, + * "filename": "Screenshot 2023-09-04 at 5.08.31\u202fPM.png", + * "purpose": "dispute_evidence", + * "size": 21444, + * "title": null, + * "type": "png", + * } + * + * @param WP_REST_Request $request Full data about the request. + * + * @return mixed|WP_Error + */ + public function get_file_detail( WP_REST_Request $request ) { + $file_id = $request->get_param( 'file_id' ); + $as_account = (bool) $request->get_param( 'as_account' ); + + return $this->forward_request( 'get_file', [ $file_id, $as_account ] ); + } + + /** + * Retrieve file contents via the API as a base64 encoded string. + * + * Example response: + * { + * "content_type": "image\/png", + * "file_content": "iVBORw.......", + * } + * + * @param WP_REST_Request $request Full data about the request. + * + * @return mixed|WP_Error + */ + public function get_file_content( WP_REST_Request $request ) { + $file_id = $request->get_param( 'file_id' ); + $as_account = (bool) $request->get_param( 'as_account' ); + + return $this->forward_request( 'get_file_contents', [ $file_id, $as_account ] ); + } + /** * Convert error response * diff --git a/includes/admin/class-wc-rest-payments-orders-controller.php b/includes/admin/class-wc-rest-payments-orders-controller.php index 782c5358762..b5915678ca7 100644 --- a/includes/admin/class-wc-rest-payments-orders-controller.php +++ b/includes/admin/class-wc-rest-payments-orders-controller.php @@ -499,7 +499,7 @@ public function cancel_authorization( WP_REST_Request $request ) { $result = $this->gateway->cancel_authorization( $order ); - if ( Intent_Status::SUCCEEDED !== $result['status'] ) { + if ( Intent_Status::CANCELED !== $result['status'] ) { return new WP_Error( 'wcpay_cancel_error', sprintf( diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php index c83d211399e..ae5ca0361d2 100644 --- a/includes/admin/class-wc-rest-payments-settings-controller.php +++ b/includes/admin/class-wc-rest-payments-settings-controller.php @@ -271,9 +271,38 @@ public function register_routes() { 'default' => array_keys( $wcpay_form_fields['payment_request_button_locations']['options'] ), 'validate_callback' => 'rest_validate_request_arg', ], + 'is_stripe_billing_enabled' => [ + 'description' => __( 'If Stripe Billing is enabled.', 'woocommerce-payments' ), + 'type' => 'boolean', + 'validate_callback' => 'rest_validate_request_arg', + ], + 'is_migrating_stripe_billing' => [ + 'description' => __( 'Whether there is a Stripe Billing off-site to on-site billing migration in progress.', 'woocommerce-payments' ), + 'type' => 'boolean', + 'validate_callback' => 'rest_validate_request_arg', + ], + 'stripe_billing_subscription_count' => [ + 'description' => __( 'The number of subscriptions using Stripe Billing', 'woocommerce-payments' ), + 'type' => 'int', + 'validate_callback' => 'rest_validate_request_arg', + ], + 'stripe_billing_migrated_count' => [ + 'description' => __( 'The number of subscriptions migrated from Stripe Billing to on-site billing.', 'woocommerce-payments' ), + 'type' => 'int', + 'validate_callback' => 'rest_validate_request_arg', + ], ], ] ); + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/schedule-stripe-billing-migration', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'schedule_stripe_billing_migration' ], + 'permission_callback' => [ $this, 'check_permission' ], + ] + ); } /** @@ -410,6 +439,20 @@ public function get_settings(): WP_REST_Response { ) ); + // Gather the status of the Stripe Billing migration for use on the settings page. + if ( class_exists( 'WC_Subscriptions' ) ) { + $stripe_billing_migrated_count = $this->wcpay_gateway->get_subscription_migrated_count(); + + if ( class_exists( 'WC_Payments_Subscriptions' ) ) { + $stripe_billing_migrator = WC_Payments_Subscriptions::get_stripe_billing_migrator(); + + if ( $stripe_billing_migrator ) { + $is_migrating_stripe_billing = $stripe_billing_migrator->is_migrating(); + $stripe_billing_subscription_count = $stripe_billing_migrator->get_stripe_billing_subscription_count(); + } + } + } + return new WP_REST_Response( [ 'enabled_payment_method_ids' => $enabled_payment_methods, @@ -422,10 +465,13 @@ public function get_settings(): WP_REST_Response { 'is_multi_currency_enabled' => WC_Payments_Features::is_customer_multi_currency_enabled(), 'is_client_secret_encryption_enabled' => WC_Payments_Features::is_client_secret_encryption_enabled(), 'is_wcpay_subscriptions_enabled' => WC_Payments_Features::is_wcpay_subscriptions_enabled(), + 'is_stripe_billing_enabled' => WC_Payments_Features::is_stripe_billing_enabled(), 'is_wcpay_subscriptions_eligible' => WC_Payments_Features::is_wcpay_subscriptions_eligible(), 'is_subscriptions_plugin_active' => $this->wcpay_gateway->is_subscriptions_plugin_active(), 'account_country' => $this->wcpay_gateway->get_option( 'account_country' ), 'account_statement_descriptor' => $this->wcpay_gateway->get_option( 'account_statement_descriptor' ), + 'account_statement_descriptor_kanji' => $this->wcpay_gateway->get_option( 'account_statement_descriptor_kanji' ), + 'account_statement_descriptor_kana' => $this->wcpay_gateway->get_option( 'account_statement_descriptor_kana' ), 'account_business_name' => $this->wcpay_gateway->get_option( 'account_business_name' ), 'account_business_url' => $this->wcpay_gateway->get_option( 'account_business_url' ), 'account_business_support_address' => $this->wcpay_gateway->get_option( 'account_business_support_address' ), @@ -458,6 +504,9 @@ public function get_settings(): WP_REST_Response { 'deposit_completed_waiting_period' => $this->wcpay_gateway->get_option( 'deposit_completed_waiting_period' ), 'current_protection_level' => $this->wcpay_gateway->get_option( 'current_protection_level' ), 'advanced_fraud_protection_settings' => $this->wcpay_gateway->get_option( 'advanced_fraud_protection_settings' ), + 'is_migrating_stripe_billing' => $is_migrating_stripe_billing ?? false, + 'stripe_billing_subscription_count' => $stripe_billing_subscription_count ?? 0, + 'stripe_billing_migrated_count' => $stripe_billing_migrated_count ?? 0, ] ); } @@ -488,6 +537,7 @@ public function update_settings( WP_REST_Request $request ) { // Note: Both "current_protection_level" and "advanced_fraud_protection_settings" // are handled in the below method. $this->update_fraud_protection_settings( $request ); + $this->update_is_stripe_billing_enabled( $request ); return new WP_REST_Response( [], 200 ); } @@ -895,6 +945,49 @@ private function update_fraud_protection_settings( WP_REST_Request $request ) { update_option( 'current_protection_level', $protection_level ); } + /** + * Updates the Stripe Billing Subscriptions feature status. + * + * @param WP_REST_Request $request Request object. + */ + private function update_is_stripe_billing_enabled( WP_REST_Request $request ) { + if ( ! $request->has_param( 'is_stripe_billing_enabled' ) ) { + return; + } + + $is_stripe_billing_enabled = $request->get_param( 'is_stripe_billing_enabled' ); + + update_option( WC_Payments_Features::STRIPE_BILLING_FLAG_NAME, $is_stripe_billing_enabled ? '1' : '0' ); + + // Schedule a migration if Stripe Billing was disabled and there are subscriptions to migrate. + if ( ! $is_stripe_billing_enabled ) { + $this->schedule_stripe_billing_migration(); + } + } + + /** + * Schedule a migration of Stripe Billing subscriptions. + * + * @param WP_REST_Request $request The request object. Optional. If passed, the function will return a REST response. + * + * @return WP_REST_Response|null The response object, if this is a REST request. + */ + public function schedule_stripe_billing_migration( WP_REST_Request $request = null ) { + + if ( class_exists( 'WC_Payments_Subscriptions' ) ) { + $stripe_billing_migrator = WC_Payments_Subscriptions::get_stripe_billing_migrator(); + + if ( $stripe_billing_migrator && ! $stripe_billing_migrator->is_migrating() && $stripe_billing_migrator->get_stripe_billing_subscription_count() > 0 ) { + $stripe_billing_migrator->schedule_migrate_wcpay_subscriptions_action(); + } + } + + // Return a response if this is a REST request. + if ( $request ) { + return new WP_REST_Response( [], 200 ); + } + } + /** * Get the AVS check enabled status from the ruleset config. * diff --git a/includes/class-database-cache.php b/includes/class-database-cache.php index 641efaf08a6..08a22ab66b8 100644 --- a/includes/class-database-cache.php +++ b/includes/class-database-cache.php @@ -13,11 +13,22 @@ * A class for caching data as an option in the database. */ class Database_Cache { - const ACCOUNT_KEY = 'wcpay_account_data'; - const ONBOARDING_FIELDS_DATA_KEY = 'wcpay_onboarding_fields_data'; - const BUSINESS_TYPES_KEY = 'wcpay_business_types_data'; - const CURRENCIES_KEY = 'wcpay_multi_currency_cached_currencies'; - const CUSTOMER_CURRENCIES_KEY = 'wcpay_multi_currency_customer_currencies'; + const ACCOUNT_KEY = 'wcpay_account_data'; + const ONBOARDING_FIELDS_DATA_KEY = 'wcpay_onboarding_fields_data'; + const BUSINESS_TYPES_KEY = 'wcpay_business_types_data'; + const CURRENCIES_KEY = 'wcpay_multi_currency_cached_currencies'; + const CUSTOMER_CURRENCIES_KEY = 'wcpay_multi_currency_customer_currencies'; + const PAYMENT_PROCESS_FACTORS_KEY = 'wcpay_payment_process_factors'; + + /** + * Refresh during AJAX calls is avoided, but white-listing + * a key here will allow the refresh to happen. + * + * @var string[] + */ + const AJAX_ALLOWED_KEYS = [ + self::PAYMENT_PROCESS_FACTORS_KEY, + ]; /** * Payment methods cache key prefix. Used in conjunction with the customer_id to cache a customer's payment methods. @@ -216,7 +227,10 @@ private function should_refresh_cache( string $key, $cache_contents, callable $v } // Do not refresh if doing ajax or the refresh has been disabled (running an AS job). - if ( defined( 'DOING_CRON' ) || wp_doing_ajax() || $this->refresh_disabled ) { + if ( + defined( 'DOING_CRON' ) + || ( wp_doing_ajax() && ! in_array( $key, self::AJAX_ALLOWED_KEYS, true ) ) + || $this->refresh_disabled ) { return false; } @@ -330,6 +344,9 @@ private function get_ttl( string $key, array $cache_contents ): int { case self::CONNECT_INCENTIVE_KEY: $ttl = $cache_contents['data']['ttl'] ?? HOUR_IN_SECONDS * 6; break; + case self::PAYMENT_PROCESS_FACTORS_KEY: + $ttl = 2 * HOUR_IN_SECONDS; + break; default: // Default to 24h. $ttl = DAY_IN_SECONDS; diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 59f4d39527b..a03819bbf98 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -42,6 +42,8 @@ use WCPay\Session_Rate_Limiter; use WCPay\Tracker; use WCPay\Internal\Service\PaymentProcessingService; +use WCPay\Internal\Payment\Factor; +use WCPay\Internal\Payment\Router; /** * Gateway class for WooPayments @@ -67,20 +69,22 @@ class WC_Payment_Gateway_WCPay extends WC_Payment_Gateway_CC { * @type array */ const ACCOUNT_SETTINGS_MAPPING = [ - 'account_statement_descriptor' => 'statement_descriptor', - 'account_business_name' => 'business_name', - 'account_business_url' => 'business_url', - 'account_business_support_address' => 'business_support_address', - 'account_business_support_email' => 'business_support_email', - 'account_business_support_phone' => 'business_support_phone', - 'account_branding_logo' => 'branding_logo', - 'account_branding_icon' => 'branding_icon', - 'account_branding_primary_color' => 'branding_primary_color', - 'account_branding_secondary_color' => 'branding_secondary_color', - - 'deposit_schedule_interval' => 'deposit_schedule_interval', - 'deposit_schedule_weekly_anchor' => 'deposit_schedule_weekly_anchor', - 'deposit_schedule_monthly_anchor' => 'deposit_schedule_monthly_anchor', + 'account_statement_descriptor' => 'statement_descriptor', + 'account_statement_descriptor_kanji' => 'statement_descriptor_kanji', + 'account_statement_descriptor_kana' => 'statement_descriptor_kana', + 'account_business_name' => 'business_name', + 'account_business_url' => 'business_url', + 'account_business_support_address' => 'business_support_address', + 'account_business_support_email' => 'business_support_email', + 'account_business_support_phone' => 'business_support_phone', + 'account_branding_logo' => 'branding_logo', + 'account_branding_icon' => 'branding_icon', + 'account_branding_primary_color' => 'branding_primary_color', + 'account_branding_secondary_color' => 'branding_secondary_color', + + 'deposit_schedule_interval' => 'deposit_schedule_interval', + 'deposit_schedule_weekly_anchor' => 'deposit_schedule_weekly_anchor', + 'deposit_schedule_monthly_anchor' => 'deposit_schedule_monthly_anchor', ]; /** @@ -246,7 +250,7 @@ public function __construct( 'title' => __( 'Customer bank statement', 'woocommerce-payments' ), 'description' => WC_Payments_Utils::esc_interpolated_html( __( 'Edit the way your store name appears on your customers’ bank statements (read more about requirements <a>here</a>).', 'woocommerce-payments' ), - [ 'a' => '<a href="https://woocommerce.com/document/woocommerce-payments/customization-and-translation/bank-statement-descriptor/" target="_blank" rel="noopener noreferrer">' ] + [ 'a' => '<a href="https://woocommerce.com/document/woopayments/customization-and-translation/bank-statement-descriptor/" target="_blank" rel="noopener noreferrer">' ] ), ], 'manual_capture' => [ @@ -704,6 +708,110 @@ public function payment_fields() { do_action( 'wc_payments_add_payment_fields' ); } + /** + * Checks whether the new payment process should be used to pay for a given order. + * + * @param WC_Order $order Order that's being paid. + * @return bool + */ + public function should_use_new_process( WC_Order $order ) { + $order_id = $order->get_id(); + + // The new process us under active development, and not ready for production yet. + if ( ! WC_Payments::mode()->is_dev() ) { + return false; + } + + // This array will contain all factors, present during checkout. + $factors = [ + /** + * The new payment process is a factor itself. + * Even if no other factors are present, this will make entering + * the new payment process possible only if this factor is allowed. + */ + Factor::NEW_PAYMENT_PROCESS(), + ]; + + // If there is a token in the request, we're using a saved PM. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $using_saved_payment_method = ! empty( Payment_Information::get_token_from_request( $_POST ) ); + if ( $using_saved_payment_method ) { + $factors[] = Factor::USE_SAVED_PM(); + } + + // The PM should be saved when chosen, or when it's a recurrent payment, but not if already saved. + $save_payment_method = ! $using_saved_payment_method && ( + // phpcs:ignore WordPress.Security.NonceVerification.Missing + ! empty( $_POST[ 'wc-' . static::GATEWAY_ID . '-new-payment-method' ] ) + || $this->is_payment_recurring( $order_id ) + ); + if ( $save_payment_method ) { + $factors[] = Factor::SAVE_PM(); + } + + // In case amount is 0 and we're not saving the payment method, we won't be using intents and can confirm the order payment. + if ( + apply_filters( + 'wcpay_confirm_without_payment_intent', + $order->get_total() <= 0 && ! $save_payment_method + ) + ) { + $factors[] = Factor::NO_PAYMENT(); + } + + // Subscription (both WCPay and WCSubs) if when the order contains one. + if ( function_exists( 'wcs_order_contains_subscription' ) && wcs_order_contains_subscription( $order_id ) ) { + $factors[] = Factor::SUBSCRIPTION_SIGNUP(); + } + + // WooPay might change how payment fields were loaded. + if ( + $this->woopay_util->should_enable_woopay( $this ) + && $this->woopay_util->should_enable_woopay_on_cart_or_checkout() + ) { + $factors[] = Factor::WOOPAY_ENABLED(); + } + + // WooPay payments are indicated by the platform checkout intent. + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( isset( $_POST['platform-checkout-intent'] ) ) { + $factors[] = Factor::WOOPAY_PAYMENT(); + } + + // Check whether the customer is signining up for a WCPay subscription. + if ( + function_exists( 'wcs_order_contains_subscription' ) + && wcs_order_contains_subscription( $order_id ) + && WC_Payments_Features::should_use_stripe_billing() + ) { + $factors[] = Factor::WCPAY_SUBSCRIPTION_SIGNUP(); + } + + if ( $this instanceof UPE_Split_Payment_Gateway ) { + $factors[] = Factor::DEFERRED_INTENT_SPLIT_UPE(); + } + + if ( defined( 'WCPAY_PAYMENT_REQUEST_CHECKOUT' ) && WCPAY_PAYMENT_REQUEST_CHECKOUT ) { + $factors[] = Factor::PAYMENT_REQUEST(); + } + + $router = wcpay_get_container()->get( Router::class ); + return $router->should_use_new_payment_process( $factors ); + } + + /** + * Checks whether the new payment process should be entered, + * and if the answer is yes, uses it and returns the result. + * + * @param WC_Order $order Order that needs payment. + * @return array|null Array if processed, null if the new process is not supported. + */ + public function new_process_payment( WC_Order $order ) { + // Important: No factors are provided here, they were meant just for `Feature`. + $service = wcpay_get_container()->get( PaymentProcessingService::class ); + return $service->process_payment( $order->get_id() ); + } + /** * Process the payment for a given order. * @@ -714,14 +822,13 @@ public function payment_fields() { * @throws Exception Error processing the payment. */ public function process_payment( $order_id ) { + $order = wc_get_order( $order_id ); - if ( defined( 'WCPAY_NEW_PROCESS' ) && true === WCPAY_NEW_PROCESS ) { - $new_process = wcpay_get_container()->get( PaymentProcessingService::class ); - return $new_process->process_payment( $order_id ); + // Use the new payment process if allowed. + if ( $this->should_use_new_process( $order ) ) { + return $this->new_process_payment( $order ); } - $order = wc_get_order( $order_id ); - try { if ( 20 < strlen( $order->get_billing_phone() ) ) { throw new Process_Payment_Exception( @@ -1485,30 +1592,33 @@ public function set_payment_method_title_for_order( $order, $payment_method_type * @return array Array of keyed metadata values. */ protected function get_metadata_from_order( $order, $payment_type ) { + if ( $this instanceof UPE_Split_Payment_Gateway ) { + $gateway_type = 'split_upe'; + } elseif ( $this instanceof UPE_Payment_Gateway ) { + $gateway_type = 'upe'; + } else { + $gateway_type = 'classic'; + } $name = sanitize_text_field( $order->get_billing_first_name() ) . ' ' . sanitize_text_field( $order->get_billing_last_name() ); $email = sanitize_email( $order->get_billing_email() ); $metadata = [ - 'customer_name' => $name, - 'customer_email' => $email, - 'site_url' => esc_url( get_site_url() ), - 'order_id' => $order->get_id(), - 'order_number' => $order->get_order_number(), - 'order_key' => $order->get_order_key(), - 'payment_type' => $payment_type, + 'customer_name' => $name, + 'customer_email' => $email, + 'site_url' => esc_url( get_site_url() ), + 'order_id' => $order->get_id(), + 'order_number' => $order->get_order_number(), + 'order_key' => $order->get_order_key(), + 'payment_type' => $payment_type, + 'gateway_type' => $gateway_type, + 'checkout_type' => $order->get_created_via(), + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', ]; - // If the order belongs to a WCPay Subscription, set the payment context to 'wcpay_subscription' (this helps with associating which fees belong to orders). - if ( 'recurring' === (string) $payment_type && ! $this->is_subscriptions_plugin_active() ) { - $subscriptions = wcs_get_subscriptions_for_order( $order, [ 'order_type' => 'any' ] ); - - foreach ( $subscriptions as $subscription ) { - if ( WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) { - $metadata['payment_context'] = 'wcpay_subscription'; - break; - } - } + if ( 'recurring' === (string) $payment_type && function_exists( 'wcs_order_contains_subscription' ) && wcs_order_contains_subscription( $order, 'any' ) ) { + $metadata['subscription_payment'] = wcs_order_contains_renewal( $order ) ? 'renewal' : 'initial'; + $metadata['payment_context'] = WC_Payments_Features::should_use_stripe_billing() ? 'wcpay_subscription' : 'regular_subscription'; } - return apply_filters( 'wcpay_metadata_from_order', $metadata, $order, $payment_type ); } @@ -1820,6 +1930,10 @@ public function get_option( $key, $empty_value = null ) { return $this->get_account_country(); case 'account_statement_descriptor': return $this->get_account_statement_descriptor(); + case 'account_statement_descriptor_kanji': + return $this->get_account_statement_descriptor_kanji(); + case 'account_statement_descriptor_kana': + return $this->get_account_statement_descriptor_kana(); case 'account_business_name': return $this->get_account_business_name(); case 'account_business_url': @@ -1971,6 +2085,41 @@ public function get_account_statement_descriptor( string $empty_value = '' ): st return $empty_value; } + /** + * Gets connected account statement descriptor. + * + * @param string $empty_value Empty value to return when not connected or fails to fetch account descriptor. + * + * @return string Statement descriptor of default value. + */ + public function get_account_statement_descriptor_kanji( string $empty_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_statement_descriptor_kanji(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account statement descriptor.' . $e ); + } + return $empty_value; + } + + /** + * Gets connected account statement descriptor. + * + * @param string $empty_value Empty value to return when not connected or fails to fetch account descriptor. + * + * @return string Statement descriptor of default value. + */ + public function get_account_statement_descriptor_kana( string $empty_value = '' ): string { + try { + if ( $this->is_connected() ) { + return $this->account->get_statement_descriptor_kana(); + } + } catch ( Exception $e ) { + Logger::error( 'Failed to get account statement descriptor.' . $e ); + } + return $empty_value; + } /** * Gets account default currency. diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index 13f6b6a15e9..10680f70f5d 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -275,6 +275,26 @@ public function get_statement_descriptor() : string { return ! empty( $account ) && isset( $account['statement_descriptor'] ) ? $account['statement_descriptor'] : ''; } + /** + * Gets the account statement descriptor for rendering on the settings page. + * + * @return string Account statement descriptor. + */ + public function get_statement_descriptor_kanji() : string { + $account = $this->get_cached_account_data(); + return ! empty( $account ) && isset( $account['statement_descriptor_kanji'] ) ? $account['statement_descriptor_kanji'] : ''; + } + + /** + * Gets the account statement descriptor for rendering on the settings page. + * + * @return string Account statement descriptor. + */ + public function get_statement_descriptor_kana() : string { + $account = $this->get_cached_account_data(); + return ! empty( $account ) && isset( $account['statement_descriptor_kana'] ) ? $account['statement_descriptor_kana'] : ''; + } + /** * Gets the business name. * @@ -479,6 +499,17 @@ public function get_progressive_onboarding_details(): array { ]; } + /** + * Determine whether Progressive Onboarding is in progress for this account. + * + * @return boolean + */ + public function is_progressive_onboarding_in_progress(): bool { + $account = $this->get_cached_account_data(); + return $account['progressive_onboarding']['is_enabled'] ?? false + && ! $account['progressive_onboarding']['is_complete'] ?? false; + } + /** * Gets the current account loan data for rendering on the settings pages. * @@ -858,6 +889,10 @@ public function maybe_handle_onboarding() { } if ( isset( $_GET['wcpay-disable-onboarding-test-mode'] ) ) { + // Delete the account if the dev mode is enabled otherwise it'll cause issues to onboard again. + if ( WC_Payments::mode()->is_dev() ) { + $this->payments_api_client->delete_account(); + } WC_Payments_Onboarding_Service::set_test_mode( false ); $this->redirect_to_onboarding_flow_page(); return; diff --git a/includes/class-wc-payments-apple-pay-registration.php b/includes/class-wc-payments-apple-pay-registration.php index 662865af61b..e07c74be3c0 100644 --- a/includes/class-wc-payments-apple-pay-registration.php +++ b/includes/class-wc-payments-apple-pay-registration.php @@ -411,7 +411,7 @@ public function display_error_notice() { $learn_more_text = WC_Payments_Utils::esc_interpolated_html( __( '<a>Learn more</a>.', 'woocommerce-payments' ), [ - 'a' => '<a href="https://woocommerce.com/document/woocommerce-payments/payment-methods/apple-pay/#domain-registration" target="_blank">', + 'a' => '<a href="https://woocommerce.com/document/woopayments/payment-methods/apple-pay/#domain-registration" target="_blank">', ] ); diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index 9c45b25f850..d6f098116b4 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -246,7 +246,7 @@ public function payment_fields() { __( '<strong>Test mode:</strong> use the test VISA card 4242424242424242 with any expiry date and CVC, or any test card numbers listed <a>here</a>.', 'woocommerce-payments' ), [ 'strong' => '<strong>', - 'a' => '<a href="https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/#test-cards" target="_blank">', + 'a' => '<a href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards" target="_blank">', ] ); ?> diff --git a/includes/class-wc-payments-express-checkout-button-display-handler.php b/includes/class-wc-payments-express-checkout-button-display-handler.php index 57465be523c..b5cc5b1b55b 100644 --- a/includes/class-wc-payments-express-checkout-button-display-handler.php +++ b/includes/class-wc-payments-express-checkout-button-display-handler.php @@ -57,11 +57,11 @@ public function __construct( WC_Payment_Gateway_WCPay $gateway, WC_Payments_Paym add_action( 'woocommerce_after_add_to_cart_form', [ $this, 'display_express_checkout_buttons' ], 1 ); add_action( 'woocommerce_proceed_to_checkout', [ $this, 'display_express_checkout_buttons' ], 21 ); add_action( 'woocommerce_checkout_before_customer_details', [ $this, 'display_express_checkout_buttons' ], 1 ); + } - if ( $is_payment_request_enabled ) { - // Load separator on the Pay for Order page. - add_action( 'before_woocommerce_pay_form', [ $this, 'display_express_checkout_buttons' ], 1 ); - } + if ( class_exists( '\Automattic\WooCommerce\Blocks\Package' ) && version_compare( \Automattic\WooCommerce\Blocks\Package::get_version(), '10.8.0', '>=' ) ) { + add_action( 'before_woocommerce_pay_form', [ $this, 'add_pay_for_order_params_to_js_config' ] ); + add_action( 'woocommerce_pay_order_before_payment', [ $this, 'display_express_checkout_buttons' ], 1 ); } } @@ -111,4 +111,27 @@ public function display_express_checkout_buttons() { public function is_woopay_enabled() { return $this->platform_checkout_button_handler->is_woopay_enabled(); } + + /** + * Add the Pay for order params to the JS config. + * + * @param WC_Order $order The pay-for-order order. + */ + public function add_pay_for_order_params_to_js_config( $order ) { + // phpcs:disable WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['pay_for_order'] ) && isset( $_GET['key'] ) ) { + add_filter( + 'wcpay_payment_fields_js_config', + function( $js_config ) use ( $order ) { + $js_config['order_id'] = $order->get_id(); + $js_config['pay_for_order'] = sanitize_text_field( wp_unslash( $_GET['pay_for_order'] ) ); + $js_config['key'] = sanitize_text_field( wp_unslash( $_GET['key'] ) ); + $js_config['billing_email'] = $order->get_billing_email(); + + return $js_config; + } + ); + } + // phpcs:enable WordPress.Security.NonceVerification.Recommended + } } diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index cdb963eb5bc..08485ac5e26 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -17,6 +17,7 @@ class WC_Payments_Features { const UPE_SPLIT_FLAG_NAME = '_wcpay_feature_upe_split'; const UPE_DEFERRED_INTENT_FLAG_NAME = '_wcpay_feature_upe_deferred_intent'; const WCPAY_SUBSCRIPTIONS_FLAG_NAME = '_wcpay_feature_subscriptions'; + const STRIPE_BILLING_FLAG_NAME = '_wcpay_feature_stripe_billing'; const WOOPAY_EXPRESS_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_express_checkout'; const AUTH_AND_CAPTURE_FLAG_NAME = '_wcpay_feature_auth_and_capture'; const PROGRESSIVE_ONBOARDING_FLAG_NAME = '_wcpay_feature_progressive_onboarding'; @@ -322,6 +323,51 @@ public static function is_bnpl_affirm_afterpay_enabled(): bool { return ! isset( $account['is_bnpl_affirm_afterpay_enabled'] ) || true === $account['is_bnpl_affirm_afterpay_enabled']; } + /** + * Checks whether the Stripe Billing feature is enabled. + * + * @return bool + */ + public static function is_stripe_billing_enabled(): bool { + return '1' === get_option( self::STRIPE_BILLING_FLAG_NAME, '0' ); + } + + /** + * Checks if the site is eligible for Stripe Billing. + * + * Only US merchants are eligible for Stripe Billing. + * + * @return bool + */ + public static function is_stripe_billing_eligible() { + if ( ! function_exists( 'wc_get_base_location' ) ) { + return false; + } + + $store_base_location = wc_get_base_location(); + return ! empty( $store_base_location['country'] ) && 'US' === $store_base_location['country']; + } + + /** + * Checks whether the merchant is using WCPay Subscription or opted into Stripe Billing. + * + * Note: Stripe Billing is only used when the merchant is using WooCommerce Subscriptions and turned it on or is still using WCPay Subscriptions. + * + * @return bool + */ + public static function should_use_stripe_billing() { + // We intentionally check for the existence of the 'WC_Subscriptions' class here as we want to confirm the Plugin is active. + if ( self::is_wcpay_subscriptions_enabled() && ! class_exists( 'WC_Subscriptions' ) ) { + return true; + } + + if ( self::is_stripe_billing_enabled() && class_exists( 'WC_Subscriptions' ) ) { + return true; + } + + return false; + } + /** * Returns feature flags as an array suitable for display on the front-end. * diff --git a/includes/class-wc-payments-incentives-service.php b/includes/class-wc-payments-incentives-service.php index d59f4eb7c1e..240bbcf8782 100644 --- a/includes/class-wc-payments-incentives-service.php +++ b/includes/class-wc-payments-incentives-service.php @@ -33,6 +33,7 @@ public function __construct( Database_Cache $database_cache ) { add_action( 'admin_menu', [ $this, 'add_payments_menu_badge' ] ); add_filter( 'woocommerce_admin_allowed_promo_notes', [ $this, 'allowed_promo_notes' ] ); + add_filter( 'woocommerce_admin_woopayments_onboarding_task_badge', [ $this, 'onboarding_task_badge' ] ); } /** @@ -47,9 +48,12 @@ public function add_payments_menu_badge(): void { return; } + $badge = WC_Payments_Admin::MENU_NOTIFICATION_BADGE; foreach ( $menu as $index => $menu_item ) { - if ( 'wc-admin&path=/payments/connect' === $menu_item[2] ) { - $menu[ $index ][0] .= WC_Payments_Admin::MENU_NOTIFICATION_BADGE; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + if ( false === strpos( $menu_item[0], $badge ) && ( 'wc-admin&path=/payments/connect' === $menu_item[2] ) ) { + $menu[ $index ][0] .= $badge; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + // One menu item with a badge is more than enough. break; } } @@ -77,6 +81,23 @@ public function allowed_promo_notes( $promo_notes = [] ): array { return $promo_notes; } + /** + * Adds the WooPayments incentive badge to the onboarding task. + * + * @param string $badge Current badge. + * + * @return string + */ + public function onboarding_task_badge( string $badge ): string { + $incentive = $this->get_cached_connect_incentive(); + // Return early if there is no eligible incentive. + if ( empty( $incentive['id'] ) ) { + return $badge; + } + + return $incentive['task_badge'] ?? $badge; + } + /** * Gets and caches eligible connect incentive from the server. * diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index 04f8b53aec6..56179967d20 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -1379,6 +1379,10 @@ public function ajax_create_order() { define( 'WOOCOMMERCE_CHECKOUT', true ); } + if ( ! defined( 'WCPAY_PAYMENT_REQUEST_CHECKOUT' ) ) { + define( 'WCPAY_PAYMENT_REQUEST_CHECKOUT', true ); + } + // In case the state is required, but is missing, add a more descriptive error notice. $this->validate_state(); diff --git a/includes/class-wc-payments-upe-checkout.php b/includes/class-wc-payments-upe-checkout.php index 9edc5758465..6c54ee3e303 100644 --- a/includes/class-wc-payments-upe-checkout.php +++ b/includes/class-wc-payments-upe-checkout.php @@ -254,7 +254,7 @@ public function get_enabled_payment_method_config() { $payment_method->get_testing_instructions(), [ 'strong' => '<strong>', - 'a' => '<a href="https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/#test-cards" target="_blank">', + 'a' => '<a href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards" target="_blank">', ] ); $settings[ $payment_method_id ]['forceNetworkSavedCards'] = $gateway_for_payment_method->should_use_stripe_platform_on_checkout_page(); @@ -330,7 +330,7 @@ function() use ( $payment_fields, $upe_object_name ) { $testing_instructions, [ 'strong' => '<strong>', - 'a' => '<a href="https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/#test-cards" target="_blank">', + 'a' => '<a href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards" target="_blank">', ] ); } diff --git a/includes/class-wc-payments-utils.php b/includes/class-wc-payments-utils.php index e3e6365f941..171f11b4c55 100644 --- a/includes/class-wc-payments-utils.php +++ b/includes/class-wc-payments-utils.php @@ -213,7 +213,7 @@ public static function zero_decimal_currencies(): array { /** * List of countries enabled for Stripe platform account. See also this URL: - * https://woocommerce.com/document/woocommerce-payments/compatibility/countries/#supported-countries + * https://woocommerce.com/document/woopayments/compatibility/countries/#supported-countries * * @return string[] */ @@ -727,8 +727,7 @@ public static function is_in_progressive_onboarding_treatment_mode(): bool { 'yes' === get_option( 'woocommerce_allow_tracking' ) ); - return 'treatment' === $abtest->get_variation( 'woocommerce_payments_onboarding_progressive_express_2023_v1' ) - || 'treatment' === $abtest->get_variation( 'woocommerce_payments_onboarding_progressive_express_2023_v2' ); + return 'treatment' === $abtest->get_variation( 'woocommerce_payments_onboarding_progressive_express_2023_v3' ); } /** diff --git a/includes/class-wc-payments-woopay-button-handler.php b/includes/class-wc-payments-woopay-button-handler.php index 168ab5ee8f3..eabaa7d1cac 100644 --- a/includes/class-wc-payments-woopay-button-handler.php +++ b/includes/class-wc-payments-woopay-button-handler.php @@ -529,10 +529,6 @@ public function should_show_woopay_button() { return false; } - if ( $this->is_pay_for_order_page() ) { - return false; - } - if ( ! is_user_logged_in() ) { // On product page for a subscription product, but not logged in, making WooPay unavailable. if ( $this->is_product() ) { diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 131b61835a8..36ff9512279 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -336,6 +336,7 @@ public static function init() { include_once __DIR__ . '/core/server/request/trait-use-test-mode-only-when-dev-mode.php'; include_once __DIR__ . '/core/server/request/class-generic.php'; include_once __DIR__ . '/core/server/request/class-get-intention.php'; + include_once __DIR__ . '/core/server/request/class-get-payment-process-factors.php'; include_once __DIR__ . '/core/server/request/class-create-intention.php'; include_once __DIR__ . '/core/server/request/class-update-intention.php'; include_once __DIR__ . '/core/server/request/class-capture-intention.php'; @@ -623,22 +624,16 @@ public static function init() { new WCPay\Fraud_Prevention\Order_Fraud_And_Risk_Meta_Box( self::$order_service ); } - // Load WCPay Subscriptions. - if ( WC_Payments_Features::is_wcpay_subscriptions_enabled() ) { + // Load Stripe Billing subscription integration. + if ( self::should_load_stripe_billing_integration() ) { include_once WCPAY_ABSPATH . '/includes/subscriptions/class-wc-payments-subscriptions.php'; - WC_Payments_Subscriptions::init( self::$api_client, self::$customer_service, self::$order_service, self::$account ); + WC_Payments_Subscriptions::init( self::$api_client, self::$customer_service, self::$order_service, self::$account, self::$token_service ); } if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '7.9.0', '<' ) ) { add_action( 'woocommerce_onboarding_profile_data_updated', 'WC_Payments_Features::maybe_enable_wcpay_subscriptions_after_onboarding', 10, 2 ); } - // Load the WCPay Subscriptions migration class. - if ( WC_Payments_Features::is_subscription_migration_enabled() ) { - include_once WCPAY_ABSPATH . '/includes/subscriptions/class-wc-payments-subscriptions-migrator.php'; - new WC_Payments_Subscriptions_Migrator( self::$api_client ); - } - add_action( 'rest_api_init', [ __CLASS__, 'init_rest_api' ] ); add_action( 'woocommerce_woocommerce_payments_updated', [ __CLASS__, 'set_plugin_activation_timestamp' ] ); @@ -1711,4 +1706,39 @@ public static function wcpay_show_old_woocommerce_for_hungary_sweden_and_czech_r </div> <?php } + + /** + * Determines whether we should load Stripe Billing integration classes. + * + * Return true when: + * - the WCPay Subscriptions feature is enabled & the Woo Subscriptions plugin isn't active, or + * - Woo Subscriptions plugin is active and Stripe Billing is enabled or there are Stripe Billing Subscriptions. + * + * @see WC_Payments_Features::should_use_stripe_billing() + * + * @return bool + */ + private static function should_load_stripe_billing_integration() { + if ( WC_Payments_Features::should_use_stripe_billing() ) { + return true; + } + + // If there are any Stripe Billing Subscriptions, we should load the Stripe Billing integration classes. eg while a migration is in progress, or to support legacy subscriptions. + return function_exists( 'wcs_get_orders_with_meta_query' ) && (bool) count( + wcs_get_orders_with_meta_query( + [ + 'status' => 'any', + 'return' => 'ids', + 'type' => 'shop_subscription', + 'limit' => 1, // We only need to know if there are any - at least 1. + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => '_wcpay_subscription_id', + 'compare' => 'EXISTS', + ], + ], + ] + ) + ); + } } diff --git a/includes/class-woopay-tracker.php b/includes/class-woopay-tracker.php index 027761233ea..555143461b4 100644 --- a/includes/class-woopay-tracker.php +++ b/includes/class-woopay-tracker.php @@ -161,7 +161,7 @@ public function maybe_record_admin_event( $event, $data = [] ) { } /** - * Override parent method to omit the jetpack TOS check. + * Override parent method to omit the jetpack TOS check and include custom tracking conditions. * * @param bool $is_admin_event Indicate whether the event is emitted from admin area. * @param bool $track_on_all_stores Indicate whether the event is tracked on all WCPay stores. @@ -169,6 +169,13 @@ public function maybe_record_admin_event( $event, $data = [] ) { * @return bool */ public function should_enable_tracking( $is_admin_event = false, $track_on_all_stores = false ) { + + // Don't track if the account is not connected. + $account = WC_Payments::get_account_service(); + if ( is_null( $account ) || ! $account->is_stripe_connected() ) { + return false; + } + // Always respect the user specific opt-out cookie. if ( ! empty( $_COOKIE['tk_opt-out'] ) ) { return false; diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php index eed8333015a..a96963d8022 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -127,10 +127,10 @@ public function maybe_init_subscriptions() { 'subscriptions', ]; - if ( $this->is_subscriptions_plugin_active() ) { + if ( ! WC_Payments_Features::should_use_stripe_billing() ) { /* * Subscription amount & date changes are only supported - * when WooCommerce Subscriptions is active. + * when Stripe Billing is not in use. */ $payment_gateway_features = array_merge( $payment_gateway_features, @@ -855,12 +855,17 @@ public function update_subscription_token( $updated, $subscription, $new_token ) * Checks if a renewal order is linked to a WCPay subscription. * * @param WC_Order $renewal_order The renewal order to check. + * * @return bool True if the renewal order is linked to a renewal order. Otherwise false. */ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) { - - // Exit early if WCPay subscriptions functionality isn't enabled. - if ( ! WC_Payments_Features::is_wcpay_subscriptions_enabled() ) { + /** + * Check if WC_Payments_Subscription_Service class exists first before fetching the subscription for the renewal order. + * + * This class is only loaded when the store has the Stripe Billing feature turned on or has existing + * WCPay Subscriptions @see WC_Payments::should_load_stripe_billing_integration(). + */ + if ( ! class_exists( 'WC_Payments_Subscription_Service' ) ) { return false; } diff --git a/includes/compat/subscriptions/trait-wc-payments-subscriptions-utilities.php b/includes/compat/subscriptions/trait-wc-payments-subscriptions-utilities.php index 53a53ee2e14..b7c70fb13bb 100644 --- a/includes/compat/subscriptions/trait-wc-payments-subscriptions-utilities.php +++ b/includes/compat/subscriptions/trait-wc-payments-subscriptions-utilities.php @@ -133,4 +133,32 @@ public function get_subscriptions_core_version() { } return $subscriptions_core_instance ? $subscriptions_core_instance->get_plugin_version() : null; } + + /** + * Gets the total number of subscriptions that have already been migrated. + * + * @return int The total number of subscriptions migrated. + */ + public function get_subscription_migrated_count() { + if ( ! function_exists( 'wcs_get_orders_with_meta_query' ) ) { + return 0; + } + + return count( + wcs_get_orders_with_meta_query( + [ + 'status' => 'any', + 'return' => 'ids', + 'type' => 'shop_subscription', + 'limit' => -1, + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => '_migrated_wcpay_subscription_id', + 'compare' => 'EXISTS', + ], + ], + ] + ) + ); + } } diff --git a/includes/constants/class-base-constant.php b/includes/constants/class-base-constant.php index 7d396c8c1aa..6c2a177dbe1 100644 --- a/includes/constants/class-base-constant.php +++ b/includes/constants/class-base-constant.php @@ -27,12 +27,19 @@ abstract class Base_Constant implements \JsonSerializable { protected $value; /** - * Class constructor. + * Static objects cache. * - * @param mixed $value Constant from class. + * @var array + */ + protected static $object_cache = []; + + /** + * Class constructor. Keep it private to only allow initializing from __callStatic() + * + * @param string $value Constant from class. * @throws \InvalidArgumentException */ - public function __construct( $value ) { + private function __construct( string $value ) { if ( $value instanceof static ) { $value = $value->get_value(); } else { @@ -61,7 +68,7 @@ public function get_value() { * @return bool */ final public function equals( $variable = null ): bool { - return $variable instanceof Base_Constant && $this->get_value() === $variable->get_value() && static::class === \get_class( $variable ); + return $this === $variable; } /** @@ -92,7 +99,10 @@ public static function search( string $value ) { * @throws \InvalidArgumentException */ public static function __callStatic( $name, $arguments ) { - return new static( $name ); + if ( ! isset( static::$object_cache[ $name ] ) ) { + static::$object_cache[ $name ] = new static( $name ); + } + return static::$object_cache[ $name ]; } /** diff --git a/includes/core/CONTRIBUTING.md b/includes/core/CONTRIBUTING.md index 3487d1bdd49..a36e462c863 100644 --- a/includes/core/CONTRIBUTING.md +++ b/includes/core/CONTRIBUTING.md @@ -12,7 +12,7 @@ There are a few possible paths when it comes to services: 1. __Create a facade for an existing service:__ Create a new service class within `core/service`, which simply facades the [existing service](service/customer-service.md). Doing so will allow us to modify the facade in the future, keeping existing methods with the same parameters as existing ones. This is what was done with the [customer service](service/customer-service.md), and is the recommended way if a certain feature requires access to an existing service quickly. -2. __Move an existing service to the core directory:__ This should be done with consideration how the service could change in the future, and whether it is core to the gateway. If it more suitable to an extension (ex. [Multi-Currency](https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/?quid=92bb9bc4a89c89c9445c87865165e025)), or a consumer (ex. [WooPay](https://woocommerce.com/documentation/woopay/)), it likely needs to be somewhere else. +2. __Move an existing service to the core directory:__ This should be done with consideration how the service could change in the future, and whether it is core to the gateway. If it more suitable to an extension (ex. [Multi-Currency](https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/)), or a consumer (ex. [WooPay](https://woocommerce.com/documentation/products/woopay/)), it likely needs to be somewhere else. 3. When __creating a new service__, similarly to moving existing ones here, please consider whether the service belongs to core. If it does, do it with care, as services should be reliable and resilient. 🔗 Further information about services in core is available [within the services directory](services/README.md). diff --git a/includes/core/class-mode.php b/includes/core/class-mode.php index 89027b76313..1ed73ea7655 100644 --- a/includes/core/class-mode.php +++ b/includes/core/class-mode.php @@ -89,7 +89,7 @@ private function maybe_init() { /** * Allows WooCommerce to enter dev mode. * - * @see https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/dev-mode/ + * @see https://woocommerce.com/document/woopayments/testing-and-troubleshooting/dev-mode/ * @param bool $dev_mode The pre-determined dev mode. */ $this->dev_mode = (bool) apply_filters( 'wcpay_dev_mode', $dev_mode ); @@ -100,7 +100,7 @@ private function maybe_init() { /** * Allows WooCommerce to enter test mode. * - * @see https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/#enabling-test-mode + * @see https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#enabling-test-mode * @param bool $test_mode The pre-determined test mode. */ $this->test_mode = (bool) apply_filters( 'wcpay_test_mode', $test_mode ); diff --git a/includes/core/server/class-request.php b/includes/core/server/class-request.php index 249ae5387ac..8ac0f70d398 100644 --- a/includes/core/server/class-request.php +++ b/includes/core/server/class-request.php @@ -64,6 +64,14 @@ abstract class Request { */ private $protected_mode = false; + /** + * Stores the base class when `->apply_filters` is called. + * This class will be checked when `::extend` is called. + * + * @var string + */ + private $base_class; + /** * Holds the API client of WCPay. * @@ -101,43 +109,44 @@ abstract class Request { * @var string[] */ private $route_list = [ - WC_Payments_API_Client::ACCOUNTS_API => 'accounts', - WC_Payments_API_Client::CAPABILITIES_API => 'accounts/capabilities', - WC_Payments_API_Client::WOOPAY_ACCOUNTS_API => 'accounts/platform_checkout', - WC_Payments_API_Client::WOOPAY_COMPATIBILITY_API => 'woopay/compatibility', - WC_Payments_API_Client::APPLE_PAY_API => 'apple_pay', - WC_Payments_API_Client::CHARGES_API => 'charges', - WC_Payments_API_Client::CONN_TOKENS_API => 'terminal/connection_tokens', - WC_Payments_API_Client::TERMINAL_LOCATIONS_API => 'terminal/locations', - WC_Payments_API_Client::CUSTOMERS_API => 'customers', - WC_Payments_API_Client::CURRENCY_API => 'currency', - WC_Payments_API_Client::INTENTIONS_API => 'intentions', - WC_Payments_API_Client::REFUNDS_API => 'refunds', - WC_Payments_API_Client::DEPOSITS_API => 'deposits', - WC_Payments_API_Client::TRANSACTIONS_API => 'transactions', - WC_Payments_API_Client::DISPUTES_API => 'disputes', - WC_Payments_API_Client::FILES_API => 'files', - WC_Payments_API_Client::ONBOARDING_API => 'onboarding', - WC_Payments_API_Client::TIMELINE_API => 'timeline', - WC_Payments_API_Client::PAYMENT_METHODS_API => 'payment_methods', - WC_Payments_API_Client::SETUP_INTENTS_API => 'setup_intents', - WC_Payments_API_Client::TRACKING_API => 'tracking', - WC_Payments_API_Client::PRODUCTS_API => 'products', - WC_Payments_API_Client::PRICES_API => 'products/prices', - WC_Payments_API_Client::INVOICES_API => 'invoices', - WC_Payments_API_Client::SUBSCRIPTIONS_API => 'subscriptions', - WC_Payments_API_Client::SUBSCRIPTION_ITEMS_API => 'subscriptions/items', - WC_Payments_API_Client::READERS_CHARGE_SUMMARY => 'reader-charges/summary', - WC_Payments_API_Client::TERMINAL_READERS_API => 'terminal/readers', + WC_Payments_API_Client::ACCOUNTS_API => 'accounts', + WC_Payments_API_Client::CAPABILITIES_API => 'accounts/capabilities', + WC_Payments_API_Client::WOOPAY_ACCOUNTS_API => 'accounts/platform_checkout', + WC_Payments_API_Client::WOOPAY_COMPATIBILITY_API => 'woopay/compatibility', + WC_Payments_API_Client::APPLE_PAY_API => 'apple_pay', + WC_Payments_API_Client::CHARGES_API => 'charges', + WC_Payments_API_Client::CONN_TOKENS_API => 'terminal/connection_tokens', + WC_Payments_API_Client::TERMINAL_LOCATIONS_API => 'terminal/locations', + WC_Payments_API_Client::CUSTOMERS_API => 'customers', + WC_Payments_API_Client::CURRENCY_API => 'currency', + WC_Payments_API_Client::INTENTIONS_API => 'intentions', + WC_Payments_API_Client::REFUNDS_API => 'refunds', + WC_Payments_API_Client::DEPOSITS_API => 'deposits', + WC_Payments_API_Client::TRANSACTIONS_API => 'transactions', + WC_Payments_API_Client::DISPUTES_API => 'disputes', + WC_Payments_API_Client::FILES_API => 'files', + WC_Payments_API_Client::ONBOARDING_API => 'onboarding', + WC_Payments_API_Client::TIMELINE_API => 'timeline', + WC_Payments_API_Client::PAYMENT_METHODS_API => 'payment_methods', + WC_Payments_API_Client::SETUP_INTENTS_API => 'setup_intents', + WC_Payments_API_Client::TRACKING_API => 'tracking', + WC_Payments_API_Client::PAYMENT_PROCESS_CONFIG_API => 'payment_process_config', + WC_Payments_API_Client::PRODUCTS_API => 'products', + WC_Payments_API_Client::PRICES_API => 'products/prices', + WC_Payments_API_Client::INVOICES_API => 'invoices', + WC_Payments_API_Client::SUBSCRIPTIONS_API => 'subscriptions', + WC_Payments_API_Client::SUBSCRIPTION_ITEMS_API => 'subscriptions/items', + WC_Payments_API_Client::READERS_CHARGE_SUMMARY => 'reader-charges/summary', + WC_Payments_API_Client::TERMINAL_READERS_API => 'terminal/readers', WC_Payments_API_Client::MINIMUM_RECURRING_AMOUNT_API => 'subscriptions/minimum_amount', - WC_Payments_API_Client::CAPITAL_API => 'capital', - WC_Payments_API_Client::WEBHOOK_FETCH_API => 'webhook/failed_events', - WC_Payments_API_Client::DOCUMENTS_API => 'documents', - WC_Payments_API_Client::VAT_API => 'vat', - WC_Payments_API_Client::LINKS_API => 'links', - WC_Payments_API_Client::AUTHORIZATIONS_API => 'authorizations', - WC_Payments_API_Client::FRAUD_OUTCOMES_API => 'fraud_outcomes', - WC_Payments_API_Client::FRAUD_RULESET_API => 'fraud_ruleset', + WC_Payments_API_Client::CAPITAL_API => 'capital', + WC_Payments_API_Client::WEBHOOK_FETCH_API => 'webhook/failed_events', + WC_Payments_API_Client::DOCUMENTS_API => 'documents', + WC_Payments_API_Client::VAT_API => 'vat', + WC_Payments_API_Client::LINKS_API => 'links', + WC_Payments_API_Client::AUTHORIZATIONS_API => 'authorizations', + WC_Payments_API_Client::FRAUD_OUTCOMES_API => 'fraud_outcomes', + WC_Payments_API_Client::FRAUD_RULESET_API => 'fraud_ruleset', ]; /** @@ -415,7 +424,7 @@ private function set_params( $params ) { */ final public static function extend( Request $base_request ) { $current_class = static::class; - $base_request->validate_extended_class( $current_class, get_class( $base_request ) ); + $base_request->validate_extended_class( $current_class, $base_request->base_class ?? get_class( $base_request ) ); if ( ! $base_request->protected_mode ) { throw new Extend_Request_Exception( @@ -424,7 +433,11 @@ final public static function extend( Request $base_request ) { ); } $obj = new $current_class( $base_request->api_client, $base_request->http_interface ); - $obj->set_params( $base_request->params ); + $obj->set_params( array_merge( static::DEFAULT_PARAMS, $base_request->params ) ); + + // Carry over the base class and protected mode into the child request. + $obj->base_class = $base_request->base_class; + $obj->protected_mode = true; return $obj; } @@ -448,6 +461,7 @@ final public static function extend( Request $base_request ) { final public function apply_filters( $hook, ...$args ) { // Lock the class in order to prevent `set_param` for protected props. $this->protected_mode = true; + $this->base_class = get_class( $this ); // Validate API route. $this->validate_api_route( $this->get_api() ); @@ -544,7 +558,7 @@ public static function traverse_class_constants( string $constant_name, bool $un $constant = "$class_name::$constant_name"; if ( defined( $constant ) ) { - $keys = array_merge( $keys, constant( $constant ) ); + $keys = array_merge( constant( $constant ), $keys ); } $class_name = get_parent_class( $class_name ); diff --git a/includes/core/server/request/class-get-payment-process-factors.php b/includes/core/server/request/class-get-payment-process-factors.php new file mode 100644 index 00000000000..a5e230ffa83 --- /dev/null +++ b/includes/core/server/request/class-get-payment-process-factors.php @@ -0,0 +1,32 @@ +<?php +/** + * Class file for WCPay\Core\Server\Request\Get_Payment_Process_Factors. + * + * @package WooCommerce Payments + */ + +namespace WCPay\Core\Server\Request; + +use WCPay\Core\Server\Request; +use WC_Payments_API_Client; + +/** + * Request class for getting routing data for the new payment process. + */ +class Get_Payment_Process_Factors extends Request { + /** + * Returns the request's API. + * + * @return string + */ + public function get_api(): string { + return WC_Payments_API_Client::PAYMENT_PROCESS_CONFIG_API . '/factors'; + } + + /** + * Returns the request's HTTP method. + */ + public function get_method(): string { + return 'GET'; + } +} diff --git a/includes/core/server/request/class-update-account.php b/includes/core/server/request/class-update-account.php index 909c6789177..db63a5eda24 100644 --- a/includes/core/server/request/class-update-account.php +++ b/includes/core/server/request/class-update-account.php @@ -80,6 +80,28 @@ public function set_statement_descriptor( string $statement_descriptor ) { $this->set_param( 'statement_descriptor', $statement_descriptor ); } + /** + * Sets the account statement descriptor kanji. + * + * @param string $statement_descriptor_kanji Statement descriptor kanji. + * + * @return void + */ + public function set_statement_descriptor_kanji( string $statement_descriptor_kanji ) { + $this->set_param( 'statement_descriptor_kanji', $statement_descriptor_kanji ); + } + + /** + * Sets the account statement descriptor kana. + * + * @param string $statement_descriptor_kana Statement descriptor kana. + * + * @return void + */ + public function set_statement_descriptor_kana( string $statement_descriptor_kana ) { + $this->set_param( 'statement_descriptor_kana', $statement_descriptor_kana ); + } + /** * Sets the account business name. * diff --git a/includes/fraud-prevention/class-order-fraud-and-risk-meta-box.php b/includes/fraud-prevention/class-order-fraud-and-risk-meta-box.php index 0a2a5f6b095..28dd3e0d3d2 100644 --- a/includes/fraud-prevention/class-order-fraud-and-risk-meta-box.php +++ b/includes/fraud-prevention/class-order-fraud-and-risk-meta-box.php @@ -130,7 +130,7 @@ public function display_order_fraud_and_risk_meta_box_message( $order ) { } $callout = __( 'Learn more', 'woocommerce-payments' ); - $callout_url = 'https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/'; + $callout_url = 'https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/'; $callout_url = add_query_arg( 'status_is', 'fraud-meta-box-not-wcpay-learn-more', $callout_url ); echo '<p>' . esc_html( $description ) . '</p><a href="' . esc_url( $callout_url ) . '" target="_blank" rel="noopener noreferrer">' . esc_html( $callout ) . '</a>'; break; diff --git a/includes/multi-currency/Analytics.php b/includes/multi-currency/Analytics.php index 1c5bf22560f..949c2cd0632 100644 --- a/includes/multi-currency/Analytics.php +++ b/includes/multi-currency/Analytics.php @@ -316,9 +316,9 @@ public function filter_join_clauses( array $clauses, $context ): array { $clauses[] = "LEFT JOIN {$meta_table} {$currency_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$currency_tbl}.{$id_field} AND {$currency_tbl}.meta_key = '_order_currency'"; } - $clauses[] = "LEFT JOIN {$meta_table} {$default_currency_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$default_currency_tbl}.{$id_field} AND ${default_currency_tbl}.meta_key = '_wcpay_multi_currency_order_default_currency'"; - $clauses[] = "LEFT JOIN {$meta_table} {$exchange_rate_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$exchange_rate_tbl}.{$id_field} AND ${exchange_rate_tbl}.meta_key = '_wcpay_multi_currency_order_exchange_rate'"; - $clauses[] = "LEFT JOIN {$meta_table} {$stripe_exchange_rate_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$stripe_exchange_rate_tbl}.{$id_field} AND ${stripe_exchange_rate_tbl}.meta_key = '_wcpay_multi_currency_stripe_exchange_rate'"; + $clauses[] = "LEFT JOIN {$meta_table} {$default_currency_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$default_currency_tbl}.{$id_field} AND {$default_currency_tbl}.meta_key = '_wcpay_multi_currency_order_default_currency'"; + $clauses[] = "LEFT JOIN {$meta_table} {$exchange_rate_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$exchange_rate_tbl}.{$id_field} AND {$exchange_rate_tbl}.meta_key = '_wcpay_multi_currency_order_exchange_rate'"; + $clauses[] = "LEFT JOIN {$meta_table} {$stripe_exchange_rate_tbl} ON {$wpdb->prefix}wc_order_stats.order_id = {$stripe_exchange_rate_tbl}.{$id_field} AND {$stripe_exchange_rate_tbl}.meta_key = '_wcpay_multi_currency_stripe_exchange_rate'"; } return apply_filters( MultiCurrency::FILTER_PREFIX . 'filter_join_clauses', $clauses ); @@ -508,22 +508,28 @@ private function generate_case_when( string $variable, string $then, string $els private function has_multi_currency_orders() { global $wpdb; - // Using full SQL instad of variables to keep WPCS happy. + // Using full SQL instead of variables to keep WPCS happy. if ( $this->is_cot_enabled() ) { $result = $wpdb->get_var( - "SELECT COUNT(order_id) - FROM {$wpdb->prefix}wc_orders_meta - WHERE meta_key = '_wcpay_multi_currency_order_exchange_rate'" + "SELECT EXISTS( + SELECT 1 + FROM {$wpdb->prefix}wc_orders_meta + WHERE meta_key = '_wcpay_multi_currency_order_exchange_rate' + LIMIT 1) + AS count;" ); } else { $result = $wpdb->get_var( - "SELECT COUNT(post_id) - FROM {$wpdb->postmeta} - WHERE meta_key = '_wcpay_multi_currency_order_exchange_rate'" + "SELECT EXISTS( + SELECT 1 + FROM {$wpdb->postmeta} + WHERE meta_key = '_wcpay_multi_currency_order_exchange_rate' + LIMIT 1) + AS count;" ); } - return intval( $result ) > 0; + return intval( $result ) === 1; } /** diff --git a/includes/multi-currency/Compatibility.php b/includes/multi-currency/Compatibility.php index 31ecbfcb0f9..c88fc94cdcd 100644 --- a/includes/multi-currency/Compatibility.php +++ b/includes/multi-currency/Compatibility.php @@ -87,12 +87,41 @@ public function override_selected_currency() { } /** - * Checks to see if the widgets should be hidden. + * Deprecated method, please use should_disable_currency_switching. * * @return bool False if it shouldn't be hidden, true if it should. */ public function should_hide_widgets(): bool { - return apply_filters( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets', false ); + wc_deprecated_function( __FUNCTION__, '6.5.0', 'Compatibility::should_disable_currency_switching' ); + return $this->should_disable_currency_switching(); + } + + /** + * Checks to see if currency switching should be disabled, such as the widgets and the automatic geolocation switching. + * + * @return bool False if no, true if yes. + */ + public function should_disable_currency_switching(): bool { + $return = false; + + /** + * If the pay_for_order parameter is set, we disable currency switching. + * + * WooCommerce itself handles all the heavy lifting and verification on the Order Pay page, we just need to + * make sure the currency switchers are not displayed. This is due to once the order is created, the currency + * itself should remain static. + */ + if ( isset( $_GET['pay_for_order'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $return = true; + } + + // If someone has hooked into the deprecated filter, throw a notice and then apply the filtering. + if ( has_action( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets' ) ) { + wc_deprecated_hook( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets', '6.5.0', MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching' ); + $return = apply_filters( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets', $return ); + } + + return apply_filters( MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching', $return ); } /** diff --git a/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php b/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php index f4cbe19c902..74cc36ecc15 100644 --- a/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php +++ b/includes/multi-currency/Compatibility/WooCommerceSubscriptions.php @@ -16,6 +16,11 @@ */ class WooCommerceSubscriptions extends BaseCompatibility { + /** + * Our allowed subscription types. + */ + const SUBSCRIPTION_TYPES = [ 'renewal', 'resubscribe', 'switch' ]; + /** * Subscription switch cart item. * @@ -46,7 +51,7 @@ protected function init() { add_filter( MultiCurrency::FILTER_PREFIX . 'override_selected_currency', [ $this, 'override_selected_currency' ], 50 ); add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_product_price', [ $this, 'should_convert_product_price' ], 50, 2 ); add_filter( MultiCurrency::FILTER_PREFIX . 'should_convert_coupon_amount', [ $this, 'should_convert_coupon_amount' ], 50, 2 ); - add_filter( MultiCurrency::FILTER_PREFIX . 'should_hide_widgets', [ $this, 'should_hide_widgets' ], 50 ); + add_filter( MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching', [ $this, 'should_disable_currency_switching' ], 50 ); } } } @@ -80,11 +85,8 @@ public function get_subscription_product_signup_fee( $price, $product ) { return $price; } - $switch_cart_items = $this->get_subscription_switch_cart_items(); - if ( 0 < count( $switch_cart_items ) ) { - - // There should only ever be one item, so use that item. - $item = array_shift( $switch_cart_items ); + $item = $this->get_subscription_type_from_cart( 'switch' ); + if ( $item ) { $item_id = ! empty( $item['variation_id'] ) ? $item['variation_id'] : $item['product_id']; $switch_cart_item = $this->switch_cart_item; $this->switch_cart_item = $item['key']; @@ -112,7 +114,7 @@ public function get_subscription_product_signup_fee( $price, $product ) { // Check to see if the _subscription_sign_up_fee meta for the product has already been updated. if ( $item['key'] === $switch_cart_item ) { foreach ( $product->get_meta_data() as $meta ) { - if ( '_subscription_sign_up_fee' === $meta->get_data()['key'] && 0 < count( $meta->get_changes() ) ) { + if ( '_subscription_sign_up_fee' === $meta->get_data()['key'] && ! empty( $meta->get_changes() ) ) { return $price; } } @@ -133,7 +135,7 @@ public function get_subscription_product_signup_fee( $price, $product ) { public function maybe_disable_mixed_cart( $value ) { // If there's a subscription switch in the cart, disable multiple items in the cart. // This is so that subscriptions with different currencies cannot be added to the cart. - if ( 0 < count( $this->get_subscription_switch_cart_items() ) ) { + if ( $this->get_subscription_type_from_cart( 'switch' ) ) { return 'no'; } @@ -143,56 +145,36 @@ public function maybe_disable_mixed_cart( $value ) { /** * Checks to see if the if the selected currency needs to be overridden. * + * The running_override_selected_currency_filters property is used here to avoid infinite loops. + * * @param mixed $return Default is false, but could be three letter currency code. * * @return mixed Three letter currency code or false if not. */ public function override_selected_currency( $return ) { - // If it's not false, return it. + // If it's not false, or we are already running filters, exit. if ( $return || $this->running_override_selected_currency_filters ) { return $return; } - $subscription_renewal = $this->cart_contains_renewal(); - if ( $subscription_renewal ) { - $order = wc_get_order( $subscription_renewal['subscription_renewal']['renewal_order_id'] ); - return $order ? $order->get_currency() : $return; - } + // Loop through subscription types and check for cart items. + foreach ( self::SUBSCRIPTION_TYPES as $type ) { + $cart_item = $this->get_subscription_type_from_cart( $type ); + if ( $cart_item ) { + $this->running_override_selected_currency_filters = true; - // The running_override_selected_currency_filters property has been added here due to if it isn't, it will create an infinite loop of calls. - if ( isset( WC()->session ) && WC()->session->get( 'order_awaiting_payment' ) ) { - $this->running_override_selected_currency_filters = true; - $order = wc_get_order( WC()->session->get( 'order_awaiting_payment' ) ); - $this->running_override_selected_currency_filters = false; - if ( $order && $this->order_contains_renewal( $order ) ) { - return $order->get_currency(); - } - } + // If we have a cart item, then we can get the order or subscription to pull the currency from. + $subscription_type = 'subscription_' . $type; + $subscription = $this->get_subscription( $cart_item[ $subscription_type ]['subscription_id'] ); - // The running_override_selected_currency_filters property is used to avoid an infinite loop - // that can occur on the product page when `get_subscription()` is used. - $switch_id = $this->get_subscription_switch_id_from_superglobal(); - if ( $switch_id ) { - $this->running_override_selected_currency_filters = true; - $switch_subscription = $this->get_subscription( $switch_id ); - $this->running_override_selected_currency_filters = false; - return $switch_subscription ? $switch_subscription->get_currency() : $return; - } - - $switch_cart_items = $this->get_subscription_switch_cart_items(); - if ( 0 < count( $switch_cart_items ) ) { - $switch_cart_item = array_shift( $switch_cart_items ); - $switch_subscription = $this->get_subscription( $switch_cart_item['subscription_switch']['subscription_id'] ); - return $switch_subscription ? $switch_subscription->get_currency() : $return; - } - - $subscription_resubscribe = $this->cart_contains_resubscribe(); - if ( $subscription_resubscribe ) { - $subscription = $this->get_subscription( $subscription_resubscribe['subscription_resubscribe']['subscription_id'] ); - return $subscription ? $subscription->get_currency() : $return; + $this->running_override_selected_currency_filters = false; + return $subscription ? $subscription->get_currency() : $return; + } } - return $return; + // This instance is for when the customer lands on the product page to choose a new subscription tier. + $switch_subscription = $this->get_subscription_from_superglobal_switch_id(); + return $switch_subscription ? $switch_subscription->get_currency() : $return; } /** @@ -210,8 +192,8 @@ public function should_convert_product_price( bool $return, $product ): bool { } // Check for subscription renewal or resubscribe. - if ( $this->is_product_subscription_type_in_cart( $product, 'renewal' ) - || $this->is_product_subscription_type_in_cart( $product, 'resubscribe' ) ) { + if ( $this->get_subscription_type_from_cart( 'renewal' ) + || $this->get_subscription_type_from_cart( 'resubscribe' ) ) { $calls = [ 'WC_Cart_Totals->calculate_item_totals', 'WC_Cart->get_product_subtotal', @@ -252,7 +234,7 @@ public function should_convert_coupon_amount( bool $return, $coupon ): bool { } // If there's not a renewal in the cart, we can convert. - $subscription_renewal = $this->cart_contains_renewal(); + $subscription_renewal = $this->get_subscription_type_from_cart( 'renewal' ); if ( ! $subscription_renewal ) { return true; } @@ -272,22 +254,22 @@ public function should_convert_coupon_amount( bool $return, $coupon ): bool { } /** - * Checks to see if the widgets should be hidden. + * Checks to see if currency switching should be disabled. * * @param bool $return Whether widgets should be hidden or not. Default is false. * * @return bool */ - public function should_hide_widgets( bool $return ): bool { + public function should_disable_currency_switching( bool $return ): bool { // If it's already true, return it. if ( $return ) { return $return; } - if ( $this->cart_contains_renewal() - || $this->get_subscription_switch_id_from_superglobal() - || 0 < count( $this->get_subscription_switch_cart_items() ) - || $this->cart_contains_resubscribe() ) { + if ( $this->get_subscription_type_from_cart( 'renewal' ) + || $this->get_subscription_type_from_cart( 'resubscribe' ) + || $this->get_subscription_type_from_cart( 'switch' ) + || $this->get_subscription_from_superglobal_switch_id() ) { return true; } @@ -295,50 +277,53 @@ public function should_hide_widgets( bool $return ): bool { } /** - * Checks the cart to see if it contains a subscription product renewal. + * Checks the cart values to see if there are subscriptions with specific types present. * - * @return mixed The cart item containing the renewal as an array, else false. - */ - private function cart_contains_renewal() { - if ( ! function_exists( 'wcs_cart_contains_renewal' ) ) { - return false; - } - return wcs_cart_contains_renewal(); - } - - /** - * Checks an order to see if it contains a subscription product renewal. + * This checks both the cart itself and the session. This is due to there are times when an item may be present in + * one place and not the other. We need to make sure that if an item is in either we are not creating double conversions. * - * @param object $order Order object. + * @param string $type The type of subscription to look for in the cart. * - * @return bool The cart item containing the renewal as an array, else false. + * @return mixed False if none found, or the subscription cart item as an array. */ - private function order_contains_renewal( $order ): bool { - if ( ! function_exists( 'wcs_order_contains_renewal' ) ) { + private function get_subscription_type_from_cart( $type ) { + // Make sure we're looking for allowed types. + if ( ! in_array( $type, self::SUBSCRIPTION_TYPES, true ) ) { return false; } - return wcs_order_contains_renewal( $order ); - } - /** - * Gets the subscription switch items out of the cart. - * - * @return array Empty array or the cart items in an array.. - */ - private function get_subscription_switch_cart_items(): array { - if ( ! function_exists( 'wcs_get_order_type_cart_items' ) ) { - return []; + // Set the sub type cart key. + $subscription_type = 'subscription_' . $type; + + // Go through each cart item and if it matches the type, return that item. + if ( isset( WC()->cart ) && is_array( WC()->cart->cart_contents ) && ! empty( WC()->cart->cart_contents ) ) { + foreach ( WC()->cart->cart_contents as $cart_item ) { + if ( isset( $cart_item[ $subscription_type ] ) ) { + return $cart_item; + } + } + } + + // Go through each session cart item and if it matches the type, return that item. + if ( isset( WC()->session ) && is_array( WC()->session->get( 'cart' ) ) && ! empty( WC()->session->get( 'cart' ) ) ) { + foreach ( WC()->session->get( 'cart' ) as $cart_item ) { + if ( isset( $cart_item[ $subscription_type ] ) ) { + return $cart_item; + } + } } - return wcs_get_order_type_cart_items( 'switch' ); + + return false; } /** * Getter for subscription objects. * * @param mixed $the_subscription Post object or post ID of the order. + * * @return mixed The subscription object, or false if it cannot be found. - * Note: this is WC_Subscription|bool in normal use, but in tests - * we use WC_Order to simulate a subscription (hence `mixed`). + * Note: This should be WC_Subscription|bool, but Psalm throws errors like: + * Docblock-defined class, interface or enum named WC_Subscription does not exist (see https://psalm.dev/200) */ private function get_subscription( $the_subscription ) { if ( ! function_exists( 'wcs_get_subscription' ) ) { @@ -352,9 +337,11 @@ private function get_subscription( $the_subscription ) { * This `switch-subscription` param is added to the URL when a customer * has initiated a switch from the My Account → Subscription page. * - * @return int|bool The ID of the subscription being switched, or false if it cannot be found. + * @return mixed The subscription object, or false if it cannot be found. + * Note: This should be WC_Subscription|bool, but Psalm throws errors like: + * Docblock-defined class, interface or enum named WC_Subscription does not exist (see https://psalm.dev/200) */ - private function get_subscription_switch_id_from_superglobal() { + private function get_subscription_from_superglobal_switch_id() { // Return false if there's no nonce, or if it fails. if ( ! isset( $_GET['_wcsnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wcsnonce'] ), 'wcs_switch_request' ) ) { return false; @@ -373,9 +360,9 @@ private function get_subscription_switch_id_from_superglobal() { $switch_subscription = $this->get_subscription( $switch_id ); $this->running_override_selected_currency_filters = false; - // Confirm the sub user matches current user, and return the sub ID. + // Confirm the sub user matches current user, and return the sub. if ( $switch_subscription && $switch_subscription->get_customer_id() === get_current_user_id() ) { - return $switch_subscription->get_id(); + return $switch_subscription; } else { Logger::notice( 'User (' . get_current_user_id() . ') attempted to switch a subscription (' . $switch_subscription->get_id() . ') not assigned to them.' ); } @@ -383,58 +370,6 @@ private function get_subscription_switch_id_from_superglobal() { return false; } - /** - * Checks the cart to see if it contains a resubscription. - * - * @return mixed The cart item containing the resubscription as an array, else false. - */ - private function cart_contains_resubscribe() { - if ( ! function_exists( 'wcs_cart_contains_resubscribe' ) ) { - return false; - } - return wcs_cart_contains_resubscribe(); - } - - /** - * Checks to see if the product passed is in the cart as a subscription type. - * - * @param object $product Product to test. - * @param string $type Type of subscription. - * - * @return bool True if found in the cart, false if not. - */ - private function is_product_subscription_type_in_cart( $product, $type ): bool { - if ( ! function_exists( 'wcs_get_subscription' ) ) { - return false; - } - - $subscription = false; - - switch ( $type ) { - case 'renewal': - $subscription_item = $this->cart_contains_renewal(); - - if ( $subscription_item ) { - $subscription = wcs_get_subscription( $subscription_item['subscription_renewal']['subscription_id'] ); - } - break; - - case 'resubscribe': - $subscription_item = $this->cart_contains_resubscribe(); - - if ( $subscription_item ) { - $subscription = wcs_get_subscription( $subscription_item['subscription_resubscribe']['subscription_id'] ); - } - break; - } - - if ( $subscription && $product && $subscription->has_product( $product->get_id() ) ) { - return true; - } - - return false; - } - /** * Checks to see if the coupon passed is of a specified type. * diff --git a/includes/multi-currency/CurrencySwitcherBlock.php b/includes/multi-currency/CurrencySwitcherBlock.php index 6caf8bf7515..3b498fe7e58 100644 --- a/includes/multi-currency/CurrencySwitcherBlock.php +++ b/includes/multi-currency/CurrencySwitcherBlock.php @@ -109,15 +109,19 @@ public function init_block_widget() { * block here because the currencies enabled on a site could change, and this would not update * properly on the Gutenberg block, because it is cached. * - * @param array $block_attributes The attributes (settings) applicable to this block. We expect this will contain + * @param array $block_attributes The attributes (settings) applicable to this block. We expect this will contain * the widget title, and whether or not we should render both flags and symbols. - * @param string $content The existing widget content. Will be an empty string, because the `save()` function - * on the JS side is set to return null to force usage of the dynamic widget render_callback. * * @return string The content to be displayed inside the block widget. */ - public function render_block_widget( $block_attributes, $content ): string { - if ( $this->compatibility->should_hide_widgets() ) { + public function render_block_widget( $block_attributes ): string { + if ( $this->compatibility->should_disable_currency_switching() ) { + return ''; + } + + $enabled_currencies = $this->multi_currency->get_enabled_currencies(); + + if ( 1 === count( $enabled_currencies ) ) { return ''; } @@ -130,10 +134,10 @@ public function render_block_widget( $block_attributes, $content ): string { $widget_content = '<form>'; $widget_content .= $this->get_get_params(); - $widget_content .= '<div class="currency-switcher-holder" style="' . $div_styles . '">'; - $widget_content .= '<select name="currency" onchange="this.form.submit()" style="' . $select_styles . '">'; + $widget_content .= '<div class="currency-switcher-holder" style="' . esc_attr( $div_styles ) . '">'; + $widget_content .= '<select name="currency" onchange="this.form.submit()" style="' . esc_attr( $select_styles ) . '">'; - foreach ( $this->multi_currency->get_enabled_currencies() as $currency ) { + foreach ( $enabled_currencies as $currency ) { $widget_content .= $this->render_currency_option( $currency, $with_symbol, $with_flag ); } @@ -163,7 +167,7 @@ private function render_currency_option( Currency $currency, bool $with_symbol, $text = $currency->get_flag() . ' ' . $text; } - return '<option value="' . $code . '" ' . $selected . '>' . $text . '</option>'; + return '<option value="' . esc_attr( $code ) . '" ' . $selected . '>' . esc_html( $text ) . '</option>'; } /** diff --git a/includes/multi-currency/CurrencySwitcherWidget.php b/includes/multi-currency/CurrencySwitcherWidget.php index bb500b82d98..8850b6d0568 100644 --- a/includes/multi-currency/CurrencySwitcherWidget.php +++ b/includes/multi-currency/CurrencySwitcherWidget.php @@ -77,7 +77,7 @@ public function __construct( MultiCurrency $multi_currency, Compatibility $compa * @param array $instance Saved values from database. */ public function widget( $args, $instance ) { - if ( $this->compatibility->should_hide_widgets() ) { + if ( $this->compatibility->should_disable_currency_switching() ) { return; } diff --git a/includes/multi-currency/MultiCurrency.php b/includes/multi-currency/MultiCurrency.php index dbaa98dd0db..d55158c4d80 100644 --- a/includes/multi-currency/MultiCurrency.php +++ b/includes/multi-currency/MultiCurrency.php @@ -527,6 +527,8 @@ public function update_single_currency_settings( string $currency_code, string $ throw new InvalidCurrencyException( $message, 'wcpay_multi_currency_invalid_currency', 500 ); } + $currency_code = strtolower( $currency_code ); + if ( 'manual' === $exchange_rate_type && ! is_null( $manual_rate ) ) { if ( ! is_numeric( $manual_rate ) || 0 >= $manual_rate ) { $message = 'Invalid manual currency rate passed to update_single_currency_settings: ' . $manual_rate; @@ -536,7 +538,6 @@ public function update_single_currency_settings( string $currency_code, string $ update_option( 'wcpay_multi_currency_manual_rate_' . $currency_code, $manual_rate ); } - $currency_code = strtolower( $currency_code ); update_option( 'wcpay_multi_currency_price_rounding_' . $currency_code, $price_rounding ); update_option( 'wcpay_multi_currency_price_charm_' . $currency_code, $price_charm ); if ( in_array( $exchange_rate_type, [ 'automatic', 'manual' ], true ) ) { @@ -781,8 +782,8 @@ public function update_selected_currency_by_url() { * @return void */ public function update_selected_currency_by_geolocation() { - // We only want to automatically set the currency if this option is enabled. - if ( ! $this->is_using_auto_currency_switching() ) { + // We only want to automatically set the currency if the option is enabled and it shouldn't be disabled for any reason. + if ( ! $this->is_using_auto_currency_switching() || $this->compatibility->should_disable_currency_switching() ) { return; } @@ -1445,28 +1446,57 @@ public function is_multi_currency_settings_page(): bool { ); } + /** + * Function used to compute the customer used currencies, used as internal callable for get_all_customer_currencies function. + * + * @return array + */ + public function callable_get_customer_currencies() { + global $wpdb; + + $currencies = $this->get_available_currencies(); + $query_union = []; + + if ( class_exists( 'Automattic\WooCommerce\Utilities\OrderUtil' ) && + \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { + foreach ( $currencies as $currency ) { + $query_union[] = $wpdb->prepare( + "SELECT %s AS currency_code, EXISTS(SELECT currency FROM {$wpdb->prefix}wc_orders WHERE currency=%s LIMIT 1) AS exists_in_orders", + $currency->code, + $currency->code + ); + } + } else { + foreach ( $currencies as $currency ) { + $query_union[] = $wpdb->prepare( + "SELECT %s AS currency_code, EXISTS(SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key=%s AND meta_value=%s LIMIT 1) AS exists_in_orders", + $currency->code, + '_order_currency', + $currency->code + ); + } + } + + $sub_query = join( ' UNION ALL ', $query_union ); + $query = "SELECT currency_code FROM ( $sub_query ) as subquery WHERE subquery.exists_in_orders=1 ORDER BY currency_code ASC"; + $currencies = $wpdb->get_col( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + + return [ + 'currencies' => $currencies, + 'updated' => time(), + ]; + } + /** * Get all the currencies that have been used in the store. * * @return array */ public function get_all_customer_currencies(): array { + $data = $this->database_cache->get_or_add( Database_Cache::CUSTOMER_CURRENCIES_KEY, - function() { - global $wpdb; - if ( class_exists( 'Automattic\WooCommerce\Utilities\OrderUtil' ) && - \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { - $currencies = $wpdb->get_col( "SELECT DISTINCT(currency) FROM {$wpdb->prefix}wc_orders" ); - } else { - $currencies = $wpdb->get_col( "SELECT DISTINCT(meta_value) FROM {$wpdb->postmeta} WHERE meta_key = '_order_currency'" ); - } - - return [ - 'currencies' => $currencies, - 'updated' => time(), - ]; - }, + [ $this, 'callable_get_customer_currencies' ], function ( $data ) { // Return true if the data looks valid and was updated an hour or less ago. return is_array( $data ) && diff --git a/includes/multi-currency/SettingsOnboardCta.php b/includes/multi-currency/SettingsOnboardCta.php index 83ba7f12a40..dbe7fa15fb7 100644 --- a/includes/multi-currency/SettingsOnboardCta.php +++ b/includes/multi-currency/SettingsOnboardCta.php @@ -18,7 +18,7 @@ class SettingsOnboardCta extends \WC_Settings_Page { * * @var string */ - const LEARN_MORE_URL = 'https://woocommerce.com/document/woocommerce-payments/currencies/multi-currency-setup/'; + const LEARN_MORE_URL = 'https://woocommerce.com/document/woopayments/currencies/multi-currency-setup/'; /** * MultiCurrency instance. diff --git a/includes/notes/class-wc-payments-notes-additional-payment-methods.php b/includes/notes/class-wc-payments-notes-additional-payment-methods.php index 5eb97dc477c..0341eccc28c 100644 --- a/includes/notes/class-wc-payments-notes-additional-payment-methods.php +++ b/includes/notes/class-wc-payments-notes-additional-payment-methods.php @@ -11,8 +11,6 @@ defined( 'ABSPATH' ) || exit; -use WCPay\Tracker; - /** * Class WC_Payments_Notes_Additional_Payment_Methods */ @@ -53,11 +51,21 @@ public static function get_note() { return; } - // if the user hasn't connected their account (or the account got disconnected) do not add the note. if ( self::$account instanceof WC_Payments_Account ) { + // if the user hasn't connected their account, do not add the note. if ( ! self::$account->is_stripe_connected() ) { return; } + + // If the account hasn't completed intitial Stripe onboarding, do not add the note. + if ( self::$account->is_account_partially_onboarded() ) { + return; + } + + // If this is a PO account which has not yet completed full onboarding, do not add the note. + if ( self::$account->is_progressive_onboarding_in_progress() ) { + return; + } } $note = new Note(); @@ -71,7 +79,7 @@ public static function get_note() { 'WooPayments' ), [ - 'a' => '<a href="https://woocommerce.com/document/woocommerce-payments/payment-methods/additional-payment-methods/" target="wcpay_upe_learn_more">', + 'a' => '<a href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/" target="wcpay_upe_learn_more">', ] ) ); diff --git a/includes/notes/class-wc-payments-notes-instant-deposits-eligible.php b/includes/notes/class-wc-payments-notes-instant-deposits-eligible.php index be4ce07190a..af86a20d16e 100644 --- a/includes/notes/class-wc-payments-notes-instant-deposits-eligible.php +++ b/includes/notes/class-wc-payments-notes-instant-deposits-eligible.php @@ -11,7 +11,7 @@ defined( 'ABSPATH' ) || exit; /** - * Class WC_Payments_Notes_Set_Https_For_Checkout + * Class WC_Payments_Notes_Instant_Deposits_Eligible */ class WC_Payments_Notes_Instant_Deposits_Eligible { use NoteTraits; @@ -41,7 +41,7 @@ public static function get_note() { __( "Get immediate access to your funds when you need them – including nights, weekends, and holidays. With %s' <a>Instant Deposits feature</a>, you're able to transfer your earnings to a debit card within minutes.", 'woocommerce-payments' ), 'WooPayments' ), - [ 'a' => '<a href="https://woocommerce.com/document/woocommerce-payments/deposits/instant-deposits/">' ] + [ 'a' => '<a href="https://woocommerce.com/document/woopayments/deposits/instant-deposits/">' ] ) ); $note->set_content_data( (object) [] ); @@ -51,7 +51,7 @@ public static function get_note() { $note->add_action( self::NOTE_NAME, __( 'Request an instant deposit', 'woocommerce-payments' ), - 'https://woocommerce.com/document/woocommerce-payments/deposits/instant-deposits/#request-an-instant-deposit', + 'https://woocommerce.com/document/woopayments/deposits/instant-deposits/#request-an-instant-deposit', 'unactioned', true ); diff --git a/includes/notes/class-wc-payments-notes-set-up-refund-policy.php b/includes/notes/class-wc-payments-notes-set-up-refund-policy.php index a3fe9c7114e..27d5d8fa57e 100644 --- a/includes/notes/class-wc-payments-notes-set-up-refund-policy.php +++ b/includes/notes/class-wc-payments-notes-set-up-refund-policy.php @@ -24,7 +24,7 @@ class WC_Payments_Notes_Set_Up_Refund_Policy { /** * Name of the note for use in the database. */ - const NOTE_DOCUMENTATION_URL = 'https://woocommerce.com/document/woocommerce-refunds#how-do-i-inform-my-customers-about-the-refund-policy'; + const NOTE_DOCUMENTATION_URL = 'https://woocommerce.com/document/woocommerce-refunds/#how-do-i-inform-my-customers-about-the-refund-policy'; /** * Get the note. diff --git a/includes/notes/class-wc-payments-notes-set-up-stripelink.php b/includes/notes/class-wc-payments-notes-set-up-stripelink.php index 5185684bd7c..49db6479d33 100644 --- a/includes/notes/class-wc-payments-notes-set-up-stripelink.php +++ b/includes/notes/class-wc-payments-notes-set-up-stripelink.php @@ -27,7 +27,7 @@ class WC_Payments_Notes_Set_Up_StripeLink { /** * CTA button link */ - const NOTE_DOCUMENTATION_URL = 'https://woocommerce.com/document/woocommerce-payments/payment-methods/link-by-stripe/'; + const NOTE_DOCUMENTATION_URL = 'https://woocommerce.com/document/woopayments/payment-methods/link-by-stripe/'; /** * The account service instance. diff --git a/includes/payment-methods/class-sepa-payment-method.php b/includes/payment-methods/class-sepa-payment-method.php index 76fbadd541d..914363f7710 100644 --- a/includes/payment-methods/class-sepa-payment-method.php +++ b/includes/payment-methods/class-sepa-payment-method.php @@ -25,7 +25,7 @@ public function __construct( $token_service ) { parent::__construct( $token_service ); $this->stripe_id = self::PAYMENT_METHOD_STRIPE_ID; $this->title = 'SEPA Direct Debit'; - $this->is_reusable = true; + $this->is_reusable = false; $this->currencies = [ 'EUR' ]; $this->icon_url = plugins_url( 'assets/images/payment-methods/sepa-debit.svg', WCPAY_PLUGIN_FILE ); } diff --git a/includes/payment-methods/class-upe-payment-gateway.php b/includes/payment-methods/class-upe-payment-gateway.php index 305267c2786..60f0af9666e 100644 --- a/includes/payment-methods/class-upe-payment-gateway.php +++ b/includes/payment-methods/class-upe-payment-gateway.php @@ -1167,9 +1167,17 @@ public function maybe_filter_gateway_title( $title, $id ) { * Sets the payment method title on the order for emails. * * @param WC_Order $order WC Order object. + * + * @return void */ public function set_payment_method_title_for_email( $order ) { - $payment_method_id = $this->order_service->get_payment_method_id_for_order( $order ); + $payment_method_id = $this->order_service->get_payment_method_id_for_order( $order ); + if ( ! $payment_method_id ) { + $order->set_payment_method_title( $this->title ); + $order->save(); + + return; + } $payment_method_details = $this->payments_api_client->get_payment_method( $payment_method_id ); $payment_method_type = $this->get_payment_method_type_from_payment_details( $payment_method_details ); $this->set_payment_method_title_for_order( $order, $payment_method_type, $payment_method_details ); diff --git a/includes/subscriptions/class-wc-payments-invoice-service.php b/includes/subscriptions/class-wc-payments-invoice-service.php index 5b1e0ba81dd..66fd2dcc4f4 100644 --- a/includes/subscriptions/class-wc-payments-invoice-service.php +++ b/includes/subscriptions/class-wc-payments-invoice-service.php @@ -189,11 +189,6 @@ public function mark_pending_invoice_paid_for_subscription( WC_Subscription $sub * @throws API_Exception If the request to mark the invoice as paid fails. */ public function maybe_record_invoice_payment( int $order_id ) { - - if ( WC_Payments_Subscriptions::is_duplicate_site() ) { - return; - } - $order = wc_get_order( $order_id ); if ( ! $order || self::get_order_invoice_id( $order ) ) { @@ -203,7 +198,7 @@ public function maybe_record_invoice_payment( int $order_id ) { foreach ( wcs_get_subscriptions_for_order( $order, [ 'order_type' => [ 'parent', 'renewal' ] ] ) as $subscription ) { $invoice_id = self::get_subscription_invoice_id( $subscription ); - if ( ! $invoice_id ) { + if ( ! $invoice_id || ! WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) { continue; } @@ -314,6 +309,20 @@ public function get_and_attach_intent_info_to_order( $order, $intent_id ) { ); } + /** + * Sends a request to server to record the store's context for an invoice payment. + * + * @param string $invoice_id The subscription invoice ID. + */ + public function record_subscription_payment_context( string $invoice_id ) { + $this->payments_api_client->update_invoice( + $invoice_id, + [ + 'subscription_context' => class_exists( 'WC_Subscriptions' ) && WC_Payments_Features::is_stripe_billing_enabled() ? 'stripe_billing' : 'legacy_wcpay_subscription', + ] + ); + } + /** * Sets the subscription last invoice ID meta for WC subscription. * diff --git a/includes/subscriptions/class-wc-payments-product-service.php b/includes/subscriptions/class-wc-payments-product-service.php index ed283de271f..2c6e12bccc4 100644 --- a/includes/subscriptions/class-wc-payments-product-service.php +++ b/includes/subscriptions/class-wc-payments-product-service.php @@ -92,12 +92,16 @@ public function __construct( WC_Payments_API_Client $payments_api_client ) { return; } - add_action( 'shutdown', [ $this, 'create_or_update_products' ] ); + // Only create, update and restore/unarchive WCPay Subscription products when Stripe Billing is active. + if ( WC_Payments_Features::should_use_stripe_billing() ) { + add_action( 'shutdown', [ $this, 'create_or_update_products' ] ); + add_action( 'untrashed_post', [ $this, 'maybe_unarchive_product' ] ); + + $this->add_product_update_listeners(); + } + add_action( 'wp_trash_post', [ $this, 'maybe_archive_product' ] ); - add_action( 'untrashed_post', [ $this, 'maybe_unarchive_product' ] ); add_filter( 'woocommerce_duplicate_product_exclude_meta', [ $this, 'exclude_meta_wcpay_product' ] ); - - $this->add_product_update_listeners(); } /** diff --git a/includes/subscriptions/class-wc-payments-subscription-minimum-amount-handler.php b/includes/subscriptions/class-wc-payments-subscription-minimum-amount-handler.php index ca8f3f66928..21f28d8bf81 100644 --- a/includes/subscriptions/class-wc-payments-subscription-minimum-amount-handler.php +++ b/includes/subscriptions/class-wc-payments-subscription-minimum-amount-handler.php @@ -10,6 +10,8 @@ */ class WC_Payments_Subscription_Minimum_Amount_Handler { + use WC_Payments_Subscriptions_Utilities; + /** * The API client object. * @@ -38,7 +40,10 @@ class WC_Payments_Subscription_Minimum_Amount_Handler { */ public function __construct( WC_Payments_API_Client $api_client ) { $this->api_client = $api_client; - add_filter( 'woocommerce_subscriptions_minimum_processable_recurring_amount', [ $this, 'get_minimum_recurring_amount' ], 10, 2 ); + + if ( WC_Payments_Features::should_use_stripe_billing() ) { + add_filter( 'woocommerce_subscriptions_minimum_processable_recurring_amount', [ $this, 'get_minimum_recurring_amount' ], 10, 2 ); + } } /** diff --git a/includes/subscriptions/class-wc-payments-subscription-service.php b/includes/subscriptions/class-wc-payments-subscription-service.php index 3dc88943780..0559111be65 100644 --- a/includes/subscriptions/class-wc-payments-subscription-service.php +++ b/includes/subscriptions/class-wc-payments-subscription-service.php @@ -137,7 +137,7 @@ public function __construct( return; } - if ( ! $this->is_subscriptions_plugin_active() ) { + if ( WC_Payments_Features::should_use_stripe_billing() ) { add_action( 'woocommerce_checkout_subscription_created', [ $this, 'create_subscription' ] ); add_action( 'woocommerce_renewal_order_payment_complete', [ $this, 'create_subscription_for_manual_renewal' ] ); add_action( 'woocommerce_subscription_payment_method_updated', [ $this, 'maybe_create_subscription_from_update_payment_method' ], 10, 2 ); @@ -157,6 +157,8 @@ public function __construct( add_action( 'woocommerce_payments_changed_subscription_payment_method', [ $this, 'maybe_attempt_payment_for_subscription' ], 10, 2 ); add_action( 'woocommerce_admin_order_data_after_billing_address', [ $this, 'show_wcpay_subscription_id' ] ); + + add_action( 'woocommerce_subscription_payment_method_updated_from_' . WC_Payment_Gateway_WCPay::GATEWAY_ID, [ $this, 'maybe_cancel_subscription' ], 10, 2 ); } /** @@ -388,6 +390,8 @@ public function create_subscription( WC_Subscription $subscription ) { $subscription_data = $this->prepare_wcpay_subscription_data( $wcpay_customer_id, $subscription ); $this->validate_subscription_data( $subscription_data ); + $subscription_data['metadata']['subscription_source'] = $this->is_subscriptions_plugin_active() ? 'woo_subscriptions' : 'wcpay_subscriptions'; + $response = $this->payments_api_client->create_subscription( $subscription_data ); $this->set_wcpay_subscription_id( $subscription, $response['id'] ); @@ -557,16 +561,14 @@ public function set_pending_cancel_for_subscription( WC_Subscription $subscripti * * If the WCPay subscription's payment method was updated while there's a failed invoice, trigger a retry. * - * @param int $post_id Post ID (WC subscription ID) that had its payment method updated. - * @param int $token_id Payment Token post ID stored in DB. - * @param WC_Payment_Token $token Payment Token object. - * - * @return void + * @param int $subscription_id Post ID (WC subscription ID) that had its payment method updated. + * @param int $token_id Payment Token post ID stored in DB. + * @param WC_Payment_Token $token Payment Token object. */ - public function update_wcpay_subscription_payment_method( int $post_id, int $token_id, WC_Payment_Token $token ) { - $subscription = wcs_get_subscription( $post_id ); + public function update_wcpay_subscription_payment_method( int $subscription_id, int $token_id, WC_Payment_Token $token ) { + $subscription = wcs_get_subscription( $subscription_id ); - if ( $subscription ) { + if ( $subscription && self::is_wcpay_subscription( $subscription ) ) { $wcpay_subscription_id = $this->get_wcpay_subscription_id( $subscription ); $wcpay_payment_method_id = $token->get_token(); @@ -597,7 +599,7 @@ public function maybe_attempt_payment_for_subscription( $subscription, WC_Paymen $wcpay_invoice_id = WC_Payments_Invoice_Service::get_pending_invoice_id( $subscription ); - if ( ! $wcpay_invoice_id ) { + if ( ! $wcpay_invoice_id || ! self::is_wcpay_subscription( $subscription ) ) { return; } @@ -637,12 +639,23 @@ public function maybe_attempt_payment_for_subscription( $subscription, WC_Paymen * @return bool */ public function prevent_wcpay_subscription_changes( bool $supported, string $feature, WC_Subscription $subscription ) { + $is_stripe_billing = self::is_wcpay_subscription( $subscription ); - if ( ! self::is_wcpay_subscription( $subscription ) ) { - return $supported; + switch ( $feature ) { + case 'subscription_amount_changes': + case 'subscription_date_changes': + $supported = ! $is_stripe_billing; + break; + case 'gateway_scheduled_payments': + $supported = $is_stripe_billing; + break; + } + + if ( $is_stripe_billing ) { + $supported = in_array( $feature, $this->supports, true ) || isset( $this->feature_support_exceptions[ $subscription->get_id() ][ $feature ] ); } - return in_array( $feature, $this->supports, true ) || isset( $this->feature_support_exceptions[ $subscription->get_id() ][ $feature ] ); + return $supported; } /** @@ -815,6 +828,25 @@ public function get_recurring_item_data_for_subscription( WC_Subscription $subsc return $data; } + /** + * Cancels a WCPay subscription when a customer changes their payment method + * + * @param WC_Subscription $subscription The subscription that was updated. + * @param string $new_payment_method The subscription's new payment method ID. + */ + public function maybe_cancel_subscription( $subscription, $new_payment_method ) { + $wcpay_subscription_id = self::get_wcpay_subscription_id( $subscription ); + + if ( (bool) $wcpay_subscription_id && WC_Payment_Gateway_WCPay::GATEWAY_ID !== $new_payment_method ) { + $this->cancel_subscription( $subscription ); + + // Delete the WCPay Subscription meta but keep a record of it. + $subscription->update_meta_data( '_cancelled' . self::SUBSCRIPTION_ID_META_KEY, $wcpay_subscription_id ); + $subscription->delete_meta_data( self::SUBSCRIPTION_ID_META_KEY ); + $subscription->save(); + } + } + /** * Gets one time item data from a subscription needed to create a WCPay subscription. * @@ -868,7 +900,6 @@ private function update_subscription( WC_Subscription $subscription, array $data $response = null; if ( ! $wcpay_subscription_id ) { - Logger::log( 'There was a problem updating the WCPay subscription in: Subscription does not contain a valid subscription ID.' ); return; } @@ -1045,7 +1076,7 @@ private function validate_subscription_data( $subscription_data ) { * @return bool True if store has active WCPay subscriptions, otherwise false. */ public static function store_has_active_wcpay_subscriptions() { - $results = wcs_get_subscriptions( + $active_wcpay_subscriptions = wcs_get_subscriptions( [ 'subscriptions_per_page' => 1, 'subscription_status' => 'active', @@ -1059,7 +1090,6 @@ public static function store_has_active_wcpay_subscriptions() { ] ); - $store_has_active_wcpay_subscriptions = count( $results ) > 0; - return $store_has_active_wcpay_subscriptions; + return count( $active_wcpay_subscriptions ) > 0; } } diff --git a/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php b/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php index a728aa63f02..d91fa30405e 100644 --- a/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php +++ b/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php @@ -188,6 +188,9 @@ public function handle_invoice_paid( array $body ) { // Remove pending invoice ID in case one was recorded for previous failed renewal attempts. $this->invoice_service->mark_pending_invoice_paid_for_subscription( $subscription ); + + // Record the store's Stripe Billing environment context on the payment intent. + $this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id ); } /** @@ -248,6 +251,9 @@ public function handle_invoice_payment_failed( array $body ) { // Record invoice ID so we can trigger repayment on payment method update. $this->invoice_service->mark_pending_invoice_for_subscription( $subscription, $wcpay_invoice_id ); + + // Record the store's Stripe Billing environment context on the payment intent. + $this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id ); } /** diff --git a/includes/subscriptions/class-wc-payments-subscriptions-migrator.php b/includes/subscriptions/class-wc-payments-subscriptions-migrator.php index 913a7f4116b..d8fa2b7738a 100644 --- a/includes/subscriptions/class-wc-payments-subscriptions-migrator.php +++ b/includes/subscriptions/class-wc-payments-subscriptions-migrator.php @@ -11,8 +11,10 @@ /** * Handles migrating WCPay Subscriptions to tokenized subscriptions. + * + * This class extends the WCS_Background_Repairer for scheduling and running the individual migration actions. */ -class WC_Payments_Subscriptions_Migrator { +class WC_Payments_Subscriptions_Migrator extends WCS_Background_Repairer { /** * Valid subscription statuses to cancel a subscription at Stripe. @@ -26,11 +28,11 @@ class WC_Payments_Subscriptions_Migrator { * * @var array $migrated_meta_keys */ - private $migrated_meta_keys = [ - '_migrated_wcpay_subscription_id', - '_migrated_wcpay_billing_invoice_id', - '_migrated_wcpay_pending_invoice_id', - '_migrated_wcpay_subscription_discount_ids', + private $meta_keys_to_migrate = [ + WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY, + WC_Payments_Invoice_Service::ORDER_INVOICE_ID_KEY, + WC_Payments_Invoice_Service::PENDING_INVOICE_ID_KEY, + WC_Payments_Subscription_Service::SUBSCRIPTION_DISCOUNT_IDS_META_KEY, ]; /** @@ -40,76 +42,113 @@ class WC_Payments_Subscriptions_Migrator { */ private $api_client; + /** + * WC_Payments_Token_Service instance. + * + * @var WC_Payments_Token_Service + */ + private $token_service; + /** * WC_Payments_Subscription_Migration_Log_Handler instance. * * @var WC_Payments_Subscription_Migration_Log_Handler */ - private $logger; + protected $logger; /** - * Constructor. + * The Action Scheduler hook used to find and schedule individual migrations of WCPay Subscriptions. * - * @param WC_Payments_API_Client|null $api_client WC_Payments_API_Client instance. + * @var string */ - public function __construct( $api_client = null ) { - $this->api_client = $api_client; - $this->logger = new WC_Payments_Subscription_Migration_Log_Handler(); + public $scheduled_hook = 'wcpay_schedule_subscription_migrations'; - // Hook onto Scheduled Action to migrate wcpay subscription. - // add_action( 'wcpay_migrate_subscription', [ $this, 'migrate_wcpay_subscription' ] );. + /** + * The Action Scheduler hook to migrate a WCPay Subscription. + * + * @var string + */ + public $migrate_hook = 'wcpay_migrate_subscription'; + + /** + * The option name used to store a batch identifier for the current migration batch. + * + * @var string + */ + private $migration_batch_identifier_option = 'wcpay_subscription_migration_batch'; + + /** + * Constructor. + * + * @param WC_Payments_API_Client|null $api_client WC_Payments_API_Client instance. + * @param WC_Payments_Token_Service|null $token_service WC_Payments_Token_Service instance. + */ + public function __construct( $api_client = null, $token_service = null ) { + $this->api_client = $api_client; + $this->token_service = $token_service; + $this->logger = new WC_Payments_Subscription_Migration_Log_Handler(); // Don't copy migrated subscription meta keys to related orders. add_filter( 'wc_subscriptions_object_data', [ $this, 'exclude_migrated_meta' ], 10, 1 ); + + // Add manual migration tool to WooCommerce > Status > Tools. + add_filter( 'woocommerce_debug_tools', [ $this, 'add_manual_migration_tool' ] ); + + // Schedule the single migration action with two args. This is needed because the WCS_Background_Repairer parent class only hooks on with one arg. + add_action( $this->migrate_hook . '_retry', [ $this, 'migrate_wcpay_subscription' ], 10, 2 ); + + $this->init(); } /** - * Migrate WCPay Subscription to WC Subscriptions + * Migrates a WCPay Subscription to a tokenized WooPayments subscription powered by WC Subscriptions * * Migration process: - * 1. Validate the request to migrate subscription - * 2. Fetches the subscription from Stripe - * 3. Cancels the subscription at Stripe if it is active - * 4. Update the subscription meta to indicate that it has been migrated - * 5. Add an order note on the subscription + * 1. Validate the request to migrate subscription + * 2. Fetches the subscription from Stripe + * 3. Cancels the subscription at Stripe if it is active + * 4. Update the subscription meta to indicate that it has been migrated + * 5. Add an order note on the subscription * * @param int $subscription_id The ID of the subscription to migrate. + * @param int $attempt The number of times migration has been attempted. */ - public function migrate_wcpay_subscription( $subscription_id ) { + public function migrate_wcpay_subscription( $subscription_id, $attempt = 0 ) { try { - add_action( 'shutdown', [ $this, 'log_unexpected_shutdown' ] ); + add_action( 'action_scheduler_unexpected_shutdown', [ $this, 'handle_unexpected_shutdown' ], 10, 2 ); + add_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ], 10, 2 ); + + $this->logger->log( sprintf( 'Migrating subscription #%1$d.%2$s', $subscription_id, ( $attempt > 0 ? ' Attempt: ' . ( (int) $attempt + 1 ) : '' ) ) ); $subscription = $this->validate_subscription_to_migrate( $subscription_id ); $wcpay_subscription = $this->fetch_wcpay_subscription( $subscription ); - $this->logger->log( sprintf( 'Migrating subscription #%d (%s)', $subscription_id, $wcpay_subscription['id'] ) ); - $this->maybe_cancel_wcpay_subscription( $wcpay_subscription ); - /** - * There's a scenario where a WCPay subscription is active but has no pending renewal scheduled action. - * Once migrated, this results in an active subscription that will remain active forever, without processing a renewal order. - * - * To ensure that all migrated subscriptions have a pending scheduled action, we need to reschedule the next payment date by - * updating the date on the subscription. - */ - if ( $subscription->has_status( 'active' ) && $subscription->get_time( 'next_payment' ) > time() ) { - $new_next_payment = gmdate( 'Y-m-d H:i:s', $subscription->get_time( 'next_payment' ) + 1 ); - $subscription->update_dates( [ 'next_payment' => $new_next_payment ] ); + if ( $subscription->has_status( 'active' ) ) { + $this->update_next_payment_date( $subscription, $wcpay_subscription ); + } - $this->logger->log( sprintf( '---- Next payment date updated to %s to ensure active subscription has a pending scheduled payment.', $new_next_payment ) ); + // If the subscription is active or on-hold, verify the payment method is valid and set correctly that it continues to renew. + if ( $subscription->has_status( [ 'active', 'on-hold' ] ) ) { + $this->verify_subscription_payment_token( $subscription, $wcpay_subscription ); } $this->update_wcpay_subscription_meta( $subscription ); - $subscription->add_order_note( __( 'This subscription has been successfully migrated to a WooPayments tokenized subscription.', 'woocommerce-payments' ) ); + if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() ) { + $subscription->add_order_note( __( 'This subscription has been successfully migrated to a WooPayments tokenized subscription.', 'woocommerce-payments' ) ); + } - $this->logger->log( '---- SUCCESS: Subscription migrated.' ); + $this->logger->log( sprintf( '---- Subscription #%d migration complete.', $subscription_id ) ); } catch ( \Exception $e ) { $this->logger->log( $e->getMessage() ); + + $this->maybe_reschedule_migration( $subscription_id, $attempt, $e ); } - remove_action( 'shutdown', [ $this, 'log_unexpected_shutdown' ] ); + remove_action( 'action_scheduler_unexpected_shutdown', [ $this, 'handle_unexpected_shutdown' ] ); + remove_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ] ); } /** @@ -127,23 +166,23 @@ public function migrate_wcpay_subscription( $subscription_id ) { */ private function validate_subscription_to_migrate( $subscription_id ) { if ( ! class_exists( 'WC_Subscriptions' ) ) { - throw new \Exception( sprintf( 'Skipping migration of subscription #%d. The WooCommerce Subscriptions extension is not active.', $subscription_id ) ); + throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. The WooCommerce Subscriptions extension is not active.', $subscription_id ) ); } if ( WC_Payments_Subscriptions::is_duplicate_site() ) { - throw new \Exception( sprintf( 'Skipping migration of subscription #%d. Site is in staging mode.', $subscription_id ) ); + throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Site is in staging mode.', $subscription_id ) ); } $subscription = wcs_get_subscription( $subscription_id ); if ( ! $subscription ) { - throw new \Exception( sprintf( 'Skipping migration of subscription #%d. Subscription not found.', $subscription_id ) ); + throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription not found.', $subscription_id ) ); } $migrated_wcpay_subscription_id = $subscription->get_meta( '_migrated_wcpay_subscription_id', true ); if ( ! empty( $migrated_wcpay_subscription_id ) ) { - throw new \Exception( sprintf( 'Skipping migration of subscription #%d (%s). Subscription has already been migrated.', $subscription_id, $migrated_wcpay_subscription_id ) ); + throw new \Exception( sprintf( '---- Skipping migration of subscription #%1$d (%2$s). Subscription has already been migrated.', $subscription_id, $migrated_wcpay_subscription_id ) ); } return $subscription; @@ -164,18 +203,18 @@ private function fetch_wcpay_subscription( $subscription ) { $wcpay_subscription_id = WC_Payments_Subscription_Service::get_wcpay_subscription_id( $subscription ); if ( ! $wcpay_subscription_id ) { - throw new \Exception( sprintf( 'Skipping migration of subscription #%d. Subscription is not a WCPay Subscription.', $subscription->get_id() ) ); + throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription is not a WCPay Subscription.', $subscription->get_id() ) ); } try { // Fetch the subscription from Stripe. $wcpay_subscription = $this->api_client->get_subscription( $wcpay_subscription_id ); } catch ( API_Exception $e ) { - throw new \Exception( sprintf( 'Error migrating subscription #%d (%s). Failed to fetch the subscription. %s', $subscription->get_id(), $wcpay_subscription_id, $e->getMessage() ) ); + throw new \Exception( sprintf( '---- ERROR: Failed to fetch subscription #%1$d (%2$s) from Stripe. %3$s', $subscription->get_id(), $wcpay_subscription_id, $e->getMessage() ) ); } if ( empty( $wcpay_subscription['id'] ) || empty( $wcpay_subscription['status'] ) ) { - throw new \Exception( sprintf( 'Error migrating subscription #%d (%s). Invalid subscription data from Stripe: %s', $subscription->get_id(), $wcpay_subscription_id, var_export( $wcpay_subscription, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + throw new \Exception( sprintf( '---- ERROR: Cannot migrate subscription #%1$d (%2$s). Invalid data fetched from Stripe: %3$s', $subscription->get_id(), $wcpay_subscription_id, var_export( $wcpay_subscription, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export } return $wcpay_subscription; @@ -187,10 +226,10 @@ private function fetch_wcpay_subscription( $subscription ) { * This function checks the status on the subscription at Stripe then cancels it if it's a valid status and logs any errors. * * We skip canceling any subscriptions at Stripe that are: - * - incomplete: the subscription was created but no payment method was added to the subscription - * - incomplete_expired: the incomplete subscription expired after 24hrs of no payment method being added. - * - canceled: the subscription is already canceled - * - unpaid: this status is not used by subscriptions in WooCommerce Payments + * - incomplete: the subscription was created but no payment method was added to the subscription + * - incomplete_expired: the incomplete subscription expired after 24hrs of no payment method being added. + * - canceled: the subscription is already canceled + * - unpaid: this status is not used by subscriptions in WooCommerce Payments * * @param array $wcpay_subscription The subscription data from Stripe. * @@ -199,37 +238,45 @@ private function fetch_wcpay_subscription( $subscription ) { private function maybe_cancel_wcpay_subscription( $wcpay_subscription ) { // Valid statuses to cancel subscription at Stripe: active, past_due, trialing, paused. if ( in_array( $wcpay_subscription['status'], $this->active_statuses, true ) ) { - $this->logger->log( sprintf( '---- Subscription at Stripe has "%s" status. Canceling the subscription.', $this->get_wcpay_subscription_status( $wcpay_subscription ) ) ); + $this->logger->log( sprintf( '---- Stripe subscription (%1$s) has "%2$s" status. Canceling the subscription.', $wcpay_subscription['id'], $this->get_wcpay_subscription_status( $wcpay_subscription ) ) ); try { // Cancel the subscription in Stripe. $wcpay_subscription = $this->api_client->cancel_subscription( $wcpay_subscription['id'] ); } catch ( API_Exception $e ) { - throw new \Exception( sprintf( '---- ERROR: Failed to cancel the subscription at Stripe. %s', $e->getMessage() ) ); + throw new \Exception( sprintf( '---- ERROR: Failed to cancel the Stripe subscription (%1$s). %2$s', $wcpay_subscription['id'], $e->getMessage() ) ); } - $this->logger->log( '---- Subscription successfully canceled at Stripe.' ); + $this->logger->log( sprintf( '---- Stripe subscription (%1$s) successfully canceled.', $wcpay_subscription['id'] ) ); } else { // Statuses that don't need to be canceled: incomplete, incomplete_expired, canceled, unpaid. - $this->logger->log( sprintf( '---- Subscription has "%s" status. Skipping canceling the subscription at Stripe.', $this->get_wcpay_subscription_status( $wcpay_subscription ) ) ); + $this->logger->log( sprintf( '---- Stripe subscription (%1$s) has "%2$s" status. Skipping canceling the subscription at Stripe.', $wcpay_subscription['id'], $this->get_wcpay_subscription_status( $wcpay_subscription ) ) ); } } /** - * Moves the existing WCPay Subscription meta to new meta data prefixed with `_migrated` meta - * and deletes the old meta. + * Migrates WCPay Subscription related metadata to a new key prefixed with `_migrated` and deletes the old meta. * * @param WC_Subscription $subscription The subscription with wcpay meta saved. */ private function update_wcpay_subscription_meta( $subscription ) { $updated = false; - foreach ( $this->migrated_meta_keys as $meta_key ) { - $old_key = str_replace( '_migrated', '', $meta_key ); + /** + * If this subscription is being migrated while scheduling individual actions is on-going, make sure we store meta on the subscription + * so that it's still returned by the query in @see get_items_to_repair() to not affect the limit and pagination. + */ + $migration_start = get_option( $this->migration_batch_identifier_option, 0 ); - if ( $subscription->meta_exists( $old_key ) ) { - $subscription->update_meta_data( $meta_key, $subscription->get_meta( $old_key, true ) ); - $subscription->delete_meta_data( $old_key ); + if ( 0 !== $migration_start ) { + $subscription->update_meta_data( '_wcpay_subscription_migrated_during', $migration_start ); + $updated = true; + } + + foreach ( $this->meta_keys_to_migrate as $meta_key ) { + if ( $subscription->meta_exists( $meta_key ) ) { + $subscription->update_meta_data( '_migrated' . $meta_key, $subscription->get_meta( $meta_key, true ) ); + $subscription->delete_meta_data( $meta_key ); $updated = true; } @@ -240,17 +287,82 @@ private function update_wcpay_subscription_meta( $subscription ) { } } + /** + * Updates the subscription's next payment date in WooCommerce to ensure a smooth transition to on-site billing. + * + * There's a scenario where a WCPay subscription is active but has no pending renewal scheduled action. + * Once migrated, this results in an active subscription that will remain active forever, without processing a renewal order. + * + * To ensure that all migrated subscriptions have a pending scheduled action, we need to reschedule the next payment date by + * updating the date on the subscription. + * + * In priority order the new next payment date will be: + * - The existing WooCommerce next payment date if it's in the future. + * - The Stripe subscription's current_period_end if it's in the future. + * - A newly calculated next payment date using the WC_Subscription::calculate_date() method. + * + * @param WC_Subscription $subscription The WC Subscription being migrated. + * @param array $wcpay_subscription The subscription data from Stripe. + */ + private function update_next_payment_date( $subscription, $wcpay_subscription ) { + try { + // Just update the existing WC Subscription's next payment date if it's in the future. + if ( $subscription->get_time( 'next_payment' ) > time() ) { + $new_next_payment = gmdate( 'Y-m-d H:i:s', $subscription->get_time( 'next_payment' ) + 1 ); + + $subscription->update_dates( [ 'next_payment' => $new_next_payment ] ); + $this->logger->log( sprintf( '---- Next payment date updated to %1$s to ensure subscription #%2$d has a pending scheduled payment.', $new_next_payment, $subscription->get_id() ) ); + + return; + } + + // If the subscription was still using WooPayments, use the Stripe subscription's next payment time (current_period_end) if it's in the future. + if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() && isset( $wcpay_subscription['current_period_end'] ) && absint( $wcpay_subscription['current_period_end'] ) > time() ) { + $new_next_payment = gmdate( 'Y-m-d H:i:s', absint( $wcpay_subscription['current_period_end'] ) ); + + $subscription->update_dates( [ 'next_payment' => $new_next_payment ] ); + $this->logger->log( sprintf( '---- Next payment date updated to %1$s to match Stripe subscription record and to ensure subscription #%2$d has a pending scheduled payment.', $new_next_payment, $subscription->get_id() ) ); + + return; + } + + // Lastly calculate the next payment date. + $new_next_payment = $subscription->calculate_date( 'next_payment' ); + + if ( wcs_date_to_time( $new_next_payment ) > time() ) { + $subscription->update_dates( [ 'next_payment' => $new_next_payment ] ); + $this->logger->log( sprintf( '---- Calculated a new next payment date (%1$s) to ensure subscription #%2$d has a pending scheduled payment in the future.', $new_next_payment, $subscription->get_id() ) ); + + return; + } + + // If we got here the next payment date is in the past, the Stripe subscription is missing a "current_period_end" or it's in the past, and calculating a new date also failed. Log an error. + $this->logger->log( + sprintf( + '---- ERROR: Failed to update subscription #%1$d next payment date. Current next payment date (%2$s) is in the past, Stripe "current_period_end" data is invalid (%3$s) and an attempt to calculate a new date also failed (%4$s).', + $subscription->get_id(), + gmdate( 'Y-m-d H:i:s', $subscription->get_time( 'next_payment' ) ), + isset( $wcpay_subscription['current_period_end'] ) ? gmdate( 'Y-m-d H:i:s', absint( $wcpay_subscription['current_period_end'] ) ) : 'no data', + $new_next_payment + ) + ); + } catch ( \Exception $e ) { + $this->logger->log( sprintf( '---- ERROR: Failed to update subscription #%1$d next payment date. %2$s', $subscription->get_id(), $e->getMessage() ) ); + } + } + /** * Returns the subscription status from the WCPay subscription data for logging purposes. * - * When a subscription is on-hold, we don't change the status of the subscription at Stripe, instead, we set - * the subscription as active and set the `pause_collection` behavior to `void` so that the subscription is not charged. + * If a subscription is on-hold in WC we wouldn't have changed the status of the subscription at Stripe, instead, the + * subscription would remain active and set `pause_collection` behavior to `void` so that the subscription is not charged. * - * The purpose of this function is factor in the `paused_collection` value when determining the subscription status at Stripe. + * The purpose of this function is to handle the `paused_collection` value when mapping the subscription status at Stripe to + * a status for logging. * * @param array $wcpay_subscription The subscription data from Stripe. * - * @return string + * @return string The WCPay subscription status for logging purposes. */ private function get_wcpay_subscription_status( $wcpay_subscription ) { if ( empty( $wcpay_subscription['status'] ) ) { @@ -265,28 +377,408 @@ private function get_wcpay_subscription_status( $wcpay_subscription ) { } /** - * Don't copy migrated WCPay subscription metadata to any subscription related orders (renewal/switch/resubscribe). + * Verifies the payment token on the subscription matches the default payment method on the WCPay Subscription. * - * @param array $meta_data The meta data to be copied. + * This function does two things: + * 1. If the subscription doesn't have a WooPayments payment token, set it to the default payment method from Stripe Billing. + * 2. If the subscription has a token, verify the token matches the token on the Stripe Billing subscription * - * @return array + * @param WC_Subscription $subscription The subscription to verify the payment token on. + * @param array $wcpay_subscription The subscription data from Stripe. + */ + private function verify_subscription_payment_token( $subscription, $wcpay_subscription ) { + // If the subscription's payment method isn't set to WooPayments, we skip this token step. + if ( $subscription->get_payment_method() !== WC_Payment_Gateway_WCPay::GATEWAY_ID ) { + $this->logger->log( sprintf( '---- Skipped verifying the payment token. Subscription #%1$d has "%2$s" as the payment method.', $subscription->get_id(), $subscription->get_payment_method() ) ); + return; + } + + if ( empty( $wcpay_subscription['default_payment_method'] ) ) { + $this->logger->log( sprintf( '---- Could not verify the payment method. Stripe Billing subscription (%1$s) does not have a default payment method.', $wcpay_subscription['id'] ?? 'unknown' ) ); + return; + } + + $tokens = $subscription->get_payment_tokens(); + $token_id = end( $tokens ); + $token = ! $token_id ? null : WC_Payment_Tokens::get( $token_id ); + + // If the token matches the default payment method on the Stripe Billing subscription, we're done here. + if ( $token && $token->get_token() === $wcpay_subscription['default_payment_method'] ) { + $this->logger->log( sprintf( '---- Payment token on subscription #%1$d matches the payment method on the Stripe Billing subscription (%2$s).', $subscription->get_id(), $wcpay_subscription['id'] ?? 'unknown' ) ); + return; + } + + // At this point we know the subscription doesn't have a token or the token doesn't match, add one using the default payment method on the WCPay Subscription. + $new_token = $this->maybe_create_and_update_payment_token( $subscription, $wcpay_subscription ); + + if ( $new_token ) { + $this->logger->log( sprintf( '---- Payment token on subscription #%1$d has been updated (from %2$s to %3$s) to match the payment method on the Stripe Billing subscription.', $subscription->get_id(), $token ? $token->get_token() : 'missing', $wcpay_subscription['default_payment_method'] ) ); + } + } + + /** + * Locates a payment token or creates one if it doesn't exist, then updates the subscription with the new token. + * + * @param WC_Subscription $subscription The subscription to add the payment token to. + * @param array $wcpay_subscription The subscription data from Stripe. + * + * @return WC_Payment_Token|false The new payment token or false if the token couldn't be created. + */ + private function maybe_create_and_update_payment_token( $subscription, $wcpay_subscription ) { + $token = false; + $user = new WP_User( $subscription->get_user_id() ); + $customer_tokens = WC_Payment_Tokens::get_tokens( + [ + 'user_id' => $user->ID, + 'gateway_id' => WC_Payment_Gateway_WCPay::GATEWAY_ID, + 'limit' => WC_Payment_Gateway_WCPay::USER_FORMATTED_TOKENS_LIMIT, + ] + ); + + foreach ( $customer_tokens as $customer_token ) { + if ( $customer_token->get_token() === $wcpay_subscription['default_payment_method'] ) { + $token = $customer_token; + break; + } + } + + // If we didn't find a token linked to the subscription customer, create one. + if ( ! $token ) { + try { + $token = $this->token_service->add_payment_method_to_user( $wcpay_subscription['default_payment_method'], $user ); + $this->logger->log( sprintf( '---- Created a new payment token (%1$s) for subscription #%2$d.', $token->get_token(), $subscription->get_id() ) ); + } catch ( \Exception $e ) { + $this->logger->log( sprintf( '---- WARNING: Subscription #%1$d is missing a payment token and we failed to create one. Error: %2$s', $subscription->get_id(), $e->getMessage() ) ); + return; + } + } + + // Prevent the WC_Payments_Subscriptions class from attempting to update the Stripe Billing subscription's payment method while we set the token. + remove_action( 'woocommerce_payment_token_added_to_order', [ WC_Payments_Subscriptions::get_subscription_service(), 'update_wcpay_subscription_payment_method' ], 10 ); + + $subscription->add_payment_token( $token ); + + // Reattach. + add_action( 'woocommerce_payment_token_added_to_order', [ WC_Payments_Subscriptions::get_subscription_service(), 'update_wcpay_subscription_payment_method' ], 10, 3 ); + + return $token; + } + + /** + * Prevents migrated WCPay subscription metadata being copied to subscription related orders (renewal/switch/resubscribe). + * + * @param array $meta_data The meta data to be copied. + * @return array The meta data to be copied. */ public function exclude_migrated_meta( $meta_data ) { - foreach ( $this->migrated_meta_keys as $key ) { - unset( $meta_data[ $key ] ); + foreach ( $this->meta_keys_to_migrate as $key ) { + unset( $meta_data[ '_migrated' . $key ] ); } return $meta_data; } /** - * Log any fatal errors occurred while migrating WCPay Subscriptions. + * Logs any fatal errors that occur while processing a scheduled migrate WCPay Subscription action. + * + * @param string $action_id The Action Scheduler action ID. + * @param array $error The error data. */ - public function log_unexpected_shutdown() { - $error = error_get_last(); + public function handle_unexpected_shutdown( $action_id, $error = null ) { + $migration_args = $this->get_migration_action_args( $action_id ); + + if ( ! isset( $migration_args['migrate_subscription'], $migration_args['attempt'] ) ) { + return; + } if ( ! empty( $error['type'] ) && in_array( $error['type'], [ E_ERROR, E_PARSE, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ], true ) ) { - $this->logger->log( sprintf( '---- ERROR: %s in %s on line %s.', $error['message'] ?? 'No message', $error['file'] ?? 'no file found', $error['line'] ?? '0' ) ); + $this->logger->log( sprintf( '---- ERROR: Unexpected shutdown while migrating subscription #%1$d: %2$s in %3$s on line %4$s.', $migration_args['migrate_subscription'], $error['message'] ?? 'No message', $error['file'] ?? 'no file found', $error['line'] ?? '0' ) ); } + + $this->maybe_reschedule_migration( $migration_args['migrate_subscription'], $migration_args['attempt'] ); + } + + /** + * Handles any unexpected failures that occur while processing a single migration action + * by logging an error message and rescheduling the action to retry. + * + * @param string $action_id The Action Scheduler action ID. + * @param Exception $exception The exception thrown during action processing. + */ + public function handle_unexpected_action_failure( $action_id, $exception ) { + $migration_args = $this->get_migration_action_args( $action_id ); + + if ( ! isset( $migration_args['migrate_subscription'], $migration_args['attempt'] ) ) { + return; + } + + $this->logger->log( sprintf( '---- ERROR: Unexpected failure while migrating subscription #%1$d: %2$s', $migration_args['migrate_subscription'], $exception->getMessage() ) ); + $this->maybe_reschedule_migration( $migration_args['migrate_subscription'], $migration_args['attempt'] ); + } + + /** + * Adds a manual migration tool to WooCommerce > Status > Tools. + * + * This tool is only loaded on stores that have: + * - WC Subscriptions extension activated + * - Subscriptions with WooPayments feature disabled + * - Existing WCPay Subscriptions that can be migrated + * + * @param array $tools List of WC debug tools. + * + * @return array List of WC debug tools. + */ + public function add_manual_migration_tool( $tools ) { + if ( WC_Payments_Features::is_wcpay_subscriptions_enabled() || ! class_exists( 'WC_Subscriptions' ) ) { + return $tools; + } + + // Get number of WCPay Subscriptions that can be migrated. + $wcpay_subscriptions_count = $this->get_stripe_billing_subscription_count(); + + if ( $wcpay_subscriptions_count < 1 ) { + return $tools; + } + + // Disable the button if a migration is currently in progress. + $disabled = $this->is_migrating(); + + $tools['migrate_wcpay_subscriptions'] = [ + 'name' => __( 'Migrate Stripe Billing subscriptions', 'woocommerce-payments' ), + 'button' => $disabled ? __( 'Migration in progress', 'woocommerce-payments' ) . '…' : __( 'Migrate Subscriptions', 'woocommerce-payments' ), + 'desc' => sprintf( + // translators: %1$s is a new line character and %2$d is the number of subscriptions. + __( 'This tool will migrate all Stripe Billing subscriptions to tokenized subscriptions with WooPayments.%1$sNumber of Stripe Billing subscriptions found: %2$d', 'woocommerce-payments' ), + '<br>', + $wcpay_subscriptions_count, + ), + 'callback' => [ $this, 'schedule_migrate_wcpay_subscriptions_action' ], + 'disabled' => $disabled, + 'requires_refresh' => true, + ]; + + return $tools; + } + + /** + * Schedules the initial migration action which signals the start of the migration process. + */ + public function schedule_migrate_wcpay_subscriptions_action() { + if ( as_next_scheduled_action( $this->scheduled_hook ) ) { + return; + } + + update_option( $this->migration_batch_identifier_option, time() ); + + $this->logger->log( 'Started scheduling subscription migrations.' ); + $this->schedule_repair(); + } + + /** + * Gets the subscription ID and number of attempts from the action args. + * + * @param int $action_id The action ID to get data from. + * + * @return array + */ + private function get_migration_action_args( $action_id ) { + $action = ActionScheduler_Store::instance()->fetch_action( $action_id ); + + if ( ! $action || ( $this->migrate_hook !== $action->get_hook() && $this->migrate_hook . '_retry' !== $action->get_hook() ) ) { + return []; + } + + $action_args = $action->get_args(); + + if ( ! isset( $action_args['migrate_subscription'] ) ) { + return []; + } + + return array_merge( + [ + 'migrate_subscription' => 0, + 'attempt' => 0, + ], + $action_args + ); + } + + /** + * Reschedules a subscription migration with increasing delays depending on number of attempts. + * + * After max retries, an exception is thrown if one was passed. + * + * @param int $subscription_id The ID of the subscription to retry. + * @param int $attempt The number of times migration has been attempted. + * @param \Exception|null $exception The exception thrown during migration. + * + * @throws \Exception If max attempts and exception passed is not null. + */ + public function maybe_reschedule_migration( $subscription_id, $attempt = 0, $exception = null ) { + // Number of seconds to wait before retrying the migration, increasing with each attempt up to 7 attempts (12 hours). + $retry_schedule = [ 60, 300, 600, 1800, HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS, 12 * HOUR_IN_SECONDS ]; + + // If the exception thrown contains "Skipping migration", don't reschedule the migration. + if ( $exception && false !== strpos( $exception->getMessage(), 'Skipping migration' ) ) { + return; + } + + if ( isset( $retry_schedule[ $attempt ] ) && $attempt < 7 ) { + $this->logger->log( sprintf( '---- Rescheduling migration of subscription #%1$d.', $subscription_id ) ); + + as_schedule_single_action( + gmdate( 'U' ) + $retry_schedule[ $attempt ], + $this->migrate_hook . '_retry', + [ + 'migrate_subscription' => $subscription_id, + 'attempt' => $attempt + 1, + ] + ); + } else { + $this->logger->log( sprintf( '---- FAILED: Subscription #%d could not be migrated.', $subscription_id ) ); + + if ( $exception ) { + // Before throwing the exception, remove the action_scheduler failure hook to prevent the exception being logged again. + remove_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ] ); + + throw $exception; + } + } + } + + /** + * Override WCS_Background_Repairer methods. + */ + + /** + * Initialize class variables and hooks to handle scheduling and running migration hooks in the background. + */ + public function init() { + $this->repair_hook = $this->migrate_hook; + + parent::init(); + } + + /** + * Schedules an individual action to migrate a subscription. + * + * Overrides the parent class function to make two changes: + * 1. Don't schedule an action if one already exists. + * 2. Schedules the migration to happen in one minute instead of in one hour. + * + * @param int $item The ID of the subscription to migrate. + */ + public function update_item( $item ) { + if ( ! as_next_scheduled_action( $this->migrate_hook, [ 'migrate_subscription' => $item ] ) ) { + as_schedule_single_action( gmdate( 'U' ) + 60, $this->migrate_hook, [ 'migrate_subscription' => $item ] ); + } + + unset( $this->items_to_repair[ $item ] ); + } + + /** + * Migrates an individual subscription. + * + * The repair_item() function is called by the parent class when the individual scheduled action is run. + * This acts as a wrapper for the migrate_wcpay_subscription() function. + * + * @param int $item The ID of the subscription to migrate. + */ + public function repair_item( $item ) { + $this->migrate_wcpay_subscription( $item ); + } + + /** + * Gets a batch of 100 subscriptions to migrate. + * + * Because this function fetches items in batches using limit and paged query args, we need to make sure + * the paging of this query is consistent regardless of whether some subscriptions have been repaired/migrated in between. + * + * To do this, we use the $this->migration_batch_identifier_option value to identify subscriptions previously returned by + * this function that have been migrated so they will still be considered for paging. + * + * @param int $page The page of results to fetch. + * + * @return int[] The IDs of the subscriptions to migrate. + */ + public function get_items_to_repair( $page ) { + $items_to_migrate = wcs_get_orders_with_meta_query( + [ + 'return' => 'ids', + 'type' => 'shop_subscription', + 'limit' => 100, + 'status' => 'any', + 'paged' => $page, + 'order' => 'ASC', + 'orderby' => 'ID', + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + [ + 'key' => WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY, + 'compare' => 'EXISTS', + ], + // We need to include subscriptions which have already been migrated as part of this migration group to make + // sure correct paging is maintained. As subscriptions are migrated they would migrate the WCPay subscription ID + // meta key and therefore fall out of this query's scope - messing with the paging of future queries. + // Subscriptions with the `migrated_during` meta aren't expected to be returned by this query, they are included to pad out the earlier pages. + [ + 'key' => '_wcpay_subscription_migrated_during', + 'value' => get_option( $this->migration_batch_identifier_option, 0 ), + 'compare' => '=', + ], + ], + ] + ); + + if ( empty( $items_to_migrate ) ) { + $this->logger->log( 'Finished scheduling subscription migrations.' ); + } + + return $items_to_migrate; + } + + /** + * Gets the total number of subscriptions to migrate. + * + * @return int The total number of subscriptions to migrate. + */ + public function get_stripe_billing_subscription_count() { + return count( + wcs_get_orders_with_meta_query( + [ + 'status' => 'any', + 'return' => 'ids', + 'type' => 'shop_subscription', + 'limit' => -1, + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + [ + 'key' => WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY, + 'compare' => 'EXISTS', + ], + ], + ] + ) + ); + } + + /** + * Determines if a migration is currently in progress. + * + * A migration is considered to be in progress if the initial migration action or an individual subscription + * action (or retry) is scheduled. + * + * @return bool True if a migration is in progress, false otherwise. + */ + public function is_migrating() { + return (bool) as_next_scheduled_action( $this->scheduled_hook ) || (bool) as_next_scheduled_action( $this->migrate_hook ) || (bool) as_next_scheduled_action( $this->migrate_hook . '_retry' ); + } + + /** + * Runs any actions that need to handle the completion of the migration. + */ + protected function unschedule_background_updates() { + parent::unschedule_background_updates(); + + delete_option( $this->migration_batch_identifier_option ); } } diff --git a/includes/subscriptions/class-wc-payments-subscriptions.php b/includes/subscriptions/class-wc-payments-subscriptions.php index 7750469f500..e26020cb335 100644 --- a/includes/subscriptions/class-wc-payments-subscriptions.php +++ b/includes/subscriptions/class-wc-payments-subscriptions.php @@ -49,6 +49,13 @@ class WC_Payments_Subscriptions { */ private static $event_handler; + /** + * Instance of WC_Payments_Subscriptions_Migrator, created in init function. + * + * @var WC_Payments_Subscriptions_Migrator + */ + private static $stripe_billing_migrator; + /** * Initialize WooCommerce Payments subscriptions. (Stripe Billing) * @@ -56,8 +63,9 @@ class WC_Payments_Subscriptions { * @param WC_Payments_Customer_Service $customer_service WCPay Customer Service. * @param WC_Payments_Order_Service $order_service WCPay Order Service. * @param WC_Payments_Account $account WC_Payments_Account. + * @param WC_Payments_Token_Service $token_service WC_Payments_Token_Service. */ - public static function init( WC_Payments_API_Client $api_client, WC_Payments_Customer_Service $customer_service, WC_Payments_Order_Service $order_service, WC_Payments_Account $account ) { + public static function init( WC_Payments_API_Client $api_client, WC_Payments_Customer_Service $customer_service, WC_Payments_Order_Service $order_service, WC_Payments_Account $account, WC_Payments_Token_Service $token_service ) { // Store dependencies. self::$order_service = $order_service; @@ -83,6 +91,11 @@ public static function init( WC_Payments_API_Client $api_client, WC_Payments_Cus new WC_Payments_Subscriptions_Empty_State_Manager( $account ); new WC_Payments_Subscriptions_Onboarding_Handler( $account ); new WC_Payments_Subscription_Minimum_Amount_Handler( $api_client ); + + if ( class_exists( 'WCS_Background_Repairer' ) ) { + include_once __DIR__ . '/class-wc-payments-subscriptions-migrator.php'; + self::$stripe_billing_migrator = new WC_Payments_Subscriptions_Migrator( $api_client, $token_service ); + } } /** @@ -121,6 +134,15 @@ public static function get_subscription_service() { return self::$subscription_service; } + /** + * Returns the the Stripe Billing migrator instance. + * + * @return WC_Payments_Subscriptions_Migrator + */ + public static function get_stripe_billing_migrator() { + return self::$stripe_billing_migrator; + } + /** * Determines if this is a duplicate/staging site. * diff --git a/includes/subscriptions/templates/html-subscriptions-plugin-notice.php b/includes/subscriptions/templates/html-subscriptions-plugin-notice.php index c49906e0929..0a2e8f6fa79 100644 --- a/includes/subscriptions/templates/html-subscriptions-plugin-notice.php +++ b/includes/subscriptions/templates/html-subscriptions-plugin-notice.php @@ -38,7 +38,7 @@ esc_html__( 'Existing subscribers will need to pay for their next renewal manually, after which automatic payments will resume. You will also no longer have access to the %1$s%3$sadvanced features%4$s%2$s of WooCommerce Subscriptions.', 'woocommerce-payments' ), '<strong>', '</strong>', - '<a href="https://woocommerce.com/document/woocommerce-payments/built-in-subscriptions/comparison/" target="_blank" rel="noopener noreferrer">', + '<a href="https://woocommerce.com/document/woopayments/built-in-subscriptions/comparison/" target="_blank" rel="noopener noreferrer">', '</a>' ); ?> diff --git a/includes/subscriptions/templates/html-wcpay-deactivate-warning.php b/includes/subscriptions/templates/html-wcpay-deactivate-warning.php index 1331f77dc81..302392cc340 100644 --- a/includes/subscriptions/templates/html-wcpay-deactivate-warning.php +++ b/includes/subscriptions/templates/html-wcpay-deactivate-warning.php @@ -22,8 +22,8 @@ printf( // translators: $1 $2 $3 placeholders are opening and closing HTML link tags, linking to documentation. $4 $5 placeholders are opening and closing strong HTML tags. $6 is WooPayments. esc_html__( 'Your store has active subscriptions using the built-in %6$s functionality. Due to the %1$soff-site billing engine%3$s these subscriptions use, %4$sthey will continue to renew even after you deactivate %6$s%5$s. %2$sLearn more%3$s.', 'woocommerce-payments' ), - '<a href="https://woocommerce.com/document/woocommerce-payments/built-in-subscriptions/comparison/#billing-engine">', - '<a href="https://woocommerce.com/document/woocommerce-payments/built-in-subscriptions/deactivate/#existing-subscriptions">', + '<a href="https://woocommerce.com/document/woopayments/built-in-subscriptions/comparison/#billing-engine">', + '<a href="https://woocommerce.com/document/woopayments/built-in-subscriptions/deactivate/#existing-subscriptions">', '</a>', '<strong>', '</strong>', diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index 7118d49af43..bdb7e245106 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -59,6 +59,7 @@ class WC_Payments_API_Client { const TRACKING_API = 'tracking'; const PRODUCTS_API = 'products'; const PRICES_API = 'products/prices'; + const PAYMENT_PROCESS_CONFIG_API = 'payment_process_config'; const INVOICES_API = 'invoices'; const SUBSCRIPTIONS_API = 'subscriptions'; const SUBSCRIPTION_ITEMS_API = 'subscriptions/items'; @@ -886,7 +887,11 @@ public function get_onboarding_fields_data( string $locale = '' ): array { ); if ( ! is_array( $fields_data ) ) { - return []; + throw new API_Exception( + __( 'Onboarding field data could not be retrieved', 'woocommerce-payments' ), + 'wcpay_onboarding_fields_data_error', + 400 + ); } return $fields_data; @@ -1117,6 +1122,23 @@ public function charge_invoice( string $invoice_id, array $data = [] ) { ); } + /** + * Updates an invoice. + * + * @param string $invoice_id ID of the invoice to update. + * @param array $data Parameters to send to the invoice endpoint. Optional. Default is an empty array. + * @return array + * + * @throws API_Exception Error updating the invoice. + */ + public function update_invoice( string $invoice_id, array $data = [] ) { + return $this->request( + $data, + self::INVOICES_API . '/' . $invoice_id, + self::POST + ); + } + /** * Fetch a WCPay subscription. * @@ -2340,4 +2362,22 @@ public function get_woopay_compatibility() { false ); } + + /** + * Delete account. + * + * @return array + * @throws API_Exception + */ + public function delete_account() { + return $this->request( + [ + 'test_mode' => WC_Payments::mode()->is_dev(), // only send a test mode request if in dev mode. + ], + self::ACCOUNTS_API . '/delete', + self::POST, + true, + true + ); + } } diff --git a/includes/woopay/class-woopay-order-status-sync.php b/includes/woopay/class-woopay-order-status-sync.php index 06b5fc674de..ae53828060d 100644 --- a/includes/woopay/class-woopay-order-status-sync.php +++ b/includes/woopay/class-woopay-order-status-sync.php @@ -135,6 +135,12 @@ public static function add_topics( $topic_hooks ) { * @param integer $id ID of the webhook. */ public static function create_payload( $payload, $resource, $resource_id, $id ) { + $webhook = wc_get_webhook( $id ); + if ( 0 !== strpos( $webhook->get_delivery_url(), WooPay_Utilities::get_woopay_rest_url( 'merchant-notification' ) ) ) { + // This is not a WooPay webhook, so we don't need to modify the payload. + return $payload; + } + return [ 'blog_id' => \Jetpack_Options::get_option( 'id' ), 'order_id' => $resource_id, diff --git a/includes/woopay/class-woopay-session.php b/includes/woopay/class-woopay-session.php index c36489f4047..3f6d69b4500 100644 --- a/includes/woopay/class-woopay-session.php +++ b/includes/woopay/class-woopay-session.php @@ -21,6 +21,7 @@ use WC_Payments; use WC_Payments_Customer_Service; use WC_Payments_Features; +use WCPay\MultiCurrency\MultiCurrency; use WP_REST_Request; /** @@ -43,7 +44,9 @@ class WooPay_Session { '@^\/wc\/store(\/v[\d]+)?\/cart\/update-customer$@', '@^\/wc\/store(\/v[\d]+)?\/cart\/update-item$@', '@^\/wc\/store(\/v[\d]+)?\/cart\/extensions$@', + '@^\/wc\/store(\/v[\d]+)?\/checkout\/(?P<id>[\d]+)@', '@^\/wc\/store(\/v[\d]+)?\/checkout$@', + '@^\/wc\/store(\/v[\d]+)?\/order\/(?P<id>[\d]+)@', ]; /** @@ -53,7 +56,7 @@ class WooPay_Session { */ public static function init() { add_filter( 'determine_current_user', [ __CLASS__, 'determine_current_user_for_woopay' ], 20 ); - add_filter( 'rest_request_before_callbacks', [ __CLASS__, 'add_woopay_store_api_session_handler' ], 10, 3 ); + add_filter( 'woocommerce_session_handler', [ __CLASS__, 'add_woopay_store_api_session_handler' ], 20 ); add_action( 'woocommerce_order_payment_status_changed', [ __CLASS__, 'remove_order_customer_id_on_requests_with_verified_email' ] ); add_action( 'woopay_restore_order_customer_id', [ __CLASS__, 'restore_order_customer_id_from_requests_with_verified_email' ] ); @@ -64,31 +67,24 @@ public static function init() { * This filter is used to add a custom session handler before processing Store API request callbacks. * This is only necessary because the Store API SessionHandler currently doesn't provide an `init_session_cookie` method. * - * @param mixed $response The response object. - * @param mixed $handler The handler used for the response. - * @param WP_REST_Request $request The request used to generate the response. + * @param string $default_session_handler The default session handler class name. * - * @return mixed + * @return string The session handler class name. */ - public static function add_woopay_store_api_session_handler( $response, $handler, WP_REST_Request $request ) { - $cart_token = $request->get_header( 'Cart-Token' ); + public static function add_woopay_store_api_session_handler( $default_session_handler ) { + $cart_token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? null ) ); if ( $cart_token && + self::is_request_from_woopay() && self::is_store_api_request() && class_exists( JsonWebToken::class ) && JsonWebToken::validate( $cart_token, '@' . wp_salt() ) ) { - add_filter( - 'woocommerce_session_handler', - function ( $session_handler ) { - return SessionHandler::class; - }, - 20 - ); + return SessionHandler::class; } - return $response; + return $default_session_handler; } /** @@ -295,7 +291,13 @@ public static function get_frontend_init_session_request() { return []; } - $session = self::get_init_session_request(); + // phpcs:disable WordPress.Security.NonceVerification.Missing + $order_id = ! empty( $_POST['order_id'] ) ? absint( wp_unslash( $_POST['order_id'] ) ) : null; + $key = ! empty( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : null; + $billing_email = ! empty( $_POST['billing_email'] ) ? sanitize_text_field( wp_unslash( $_POST['billing_email'] ) ) : null; + // phpcs:enable + + $session = self::get_init_session_request( $order_id, $key, $billing_email ); $store_blog_token = ( WooPay_Utilities::get_woopay_url() === WooPay_Utilities::DEFAULT_WOOPAY_URL ) ? Jetpack_Options::get_option( 'blog_token' ) : 'dev_mode'; @@ -327,17 +329,33 @@ public static function get_frontend_init_session_request() { /** * Returns the initial session request data. * + * @param int|null $order_id Pay-for-order order ID. + * @param string|null $key Pay-for-order key. + * @param string|null $billing_email Pay-for-order billing email. * @return array The initial session request data without email and user_session. */ - private static function get_init_session_request() { - $user = wp_get_current_user(); - $customer_id = WC_Payments::get_customer_service()->get_customer_id_by_user_id( $user->ID ); + private static function get_init_session_request( $order_id = null, $key = null, $billing_email = null ) { + $user = wp_get_current_user(); + $is_pay_for_order = null !== $order_id; + $order = wc_get_order( $order_id ); + $customer_id = WC_Payments::get_customer_service()->get_customer_id_by_user_id( $user->ID ); if ( null === $customer_id ) { // create customer. $customer_data = WC_Payments_Customer_Service::map_customer_data( null, new WC_Customer( $user->ID ) ); $customer_id = WC_Payments::get_customer_service()->create_customer_for_user( $user, $customer_data ); } + if ( 0 !== $user->ID ) { + // Multicurrency selection is stored on user meta when logged in and WC session when logged out. + // This code just makes sure that currency selection is available on WC session for WooPay. + $currency = get_user_meta( $user->ID, MultiCurrency::CURRENCY_META_KEY, true ); + $currency_code = strtoupper( $currency ); + + if ( ! empty( $currency_code ) && WC()->session ) { + WC()->session->set( MultiCurrency::CURRENCY_SESSION_KEY, $currency_code ); + } + } + $account_id = WC_Payments::get_account_service()->get_stripe_account_id(); $store_logo = WC_Payments::get_gateway()->get_option( 'platform_checkout_store_logo' ); @@ -346,7 +364,9 @@ private static function get_init_session_request() { $blocks_data_extractor = new Blocks_Data_Extractor(); // This uses the same logic as the Checkout block in hydrate_from_api to get the cart and checkout data. - $cart_data = rest_preload_api_request( [], '/wc/store/v1/cart' )['/wc/store/v1/cart']['body']; + $cart_data = ! $is_pay_for_order + ? rest_preload_api_request( [], '/wc/store/v1/cart' )['/wc/store/v1/cart']['body'] + : rest_preload_api_request( [], "/wc/store/v1/order/{$order_id}?key={$key}&billing_email={$billing_email}" )[ "/wc/store/v1/order/{$order_id}?key={$key}&billing_email={$billing_email}" ]['body']; add_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' ); $preloaded_checkout_data = rest_preload_api_request( [], '/wc/store/v1/checkout' ); remove_filter( 'woocommerce_store_api_disable_nonce_check', '__return_true' ); @@ -362,10 +382,10 @@ private static function get_init_session_request() { 'store_data' => [ 'store_name' => get_bloginfo( 'name' ), 'store_logo' => ! empty( $store_logo ) ? get_rest_url( null, 'wc/v3/payments/file/' . $store_logo ) : '', - 'custom_message' => self::get_formatted_custom_message(), + 'custom_message' => WC_Payments::get_gateway()->get_option( 'platform_checkout_custom_message' ), 'blog_id' => Jetpack_Options::get_option( 'id' ), 'blog_url' => get_site_url(), - 'blog_checkout_url' => wc_get_checkout_url(), + 'blog_checkout_url' => ! $is_pay_for_order ? wc_get_checkout_url() : $order->get_checkout_payment_url(), 'blog_shop_url' => get_permalink( wc_get_page_id( 'shop' ) ), 'store_api_url' => self::get_store_api_url(), 'account_id' => $account_id, @@ -374,14 +394,19 @@ private static function get_init_session_request() { 'is_subscriptions_plugin_active' => WC_Payments::get_gateway()->is_subscriptions_plugin_active(), 'woocommerce_tax_display_cart' => get_option( 'woocommerce_tax_display_cart' ), 'ship_to_billing_address_only' => wc_ship_to_billing_address_only(), - 'return_url' => wc_get_cart_url(), + 'return_url' => ! $is_pay_for_order ? wc_get_cart_url() : $order->get_checkout_payment_url(), 'blocks_data' => $blocks_data_extractor->get_data(), 'checkout_schema_namespaces' => $blocks_data_extractor->get_checkout_schema_namespaces(), ], 'user_session' => null, - 'preloaded_requests' => [ + 'preloaded_requests' => ! $is_pay_for_order ? [ 'cart' => $cart_data, 'checkout' => $checkout_data, + ] : [ + 'cart' => $cart_data, + 'checkout' => [ + 'order_id' => $order_id, // This is a workaround for the checkout order error. https://github.com/woocommerce/woocommerce-blocks/blob/04f36065b34977f02079e6c2c8cb955200a783ff/assets/js/blocks/checkout/block.tsx#L81-L83. + ], ], 'tracks_user_identity' => WC_Payments::woopay_tracker()->tracks_get_identity( $user->ID ), ]; @@ -404,9 +429,12 @@ public static function ajax_init_woopay() { ); } - $email = ! empty( $_POST['email'] ) ? wc_clean( wp_unslash( $_POST['email'] ) ) : ''; + $email = ! empty( $_POST['email'] ) ? wc_clean( wp_unslash( $_POST['email'] ) ) : ''; + $order_id = ! empty( $_POST['order_id'] ) ? absint( wp_unslash( $_POST['order_id'] ) ) : null; + $key = ! empty( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : null; + $billing_email = ! empty( $_POST['billing_email'] ) ? sanitize_text_field( wp_unslash( $_POST['billing_email'] ) ) : null; - $body = self::get_init_session_request(); + $body = self::get_init_session_request( $order_id, $key, $billing_email ); $body['email'] = $email; $body['user_session'] = isset( $_REQUEST['user_session'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['user_session'] ) ) : null; @@ -493,10 +521,6 @@ private static function get_woopay_verified_email_address() { * @return bool True if request is a Store API request, false otherwise. */ private static function is_store_api_request(): bool { - if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ) { - return false; - } - $url_parts = wp_parse_url( esc_url_raw( $_SERVER['REQUEST_URI'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash $request_path = rtrim( $url_parts['path'], '/' ); $rest_route = str_replace( trailingslashit( rest_get_url_prefix() ), '', $request_path ); @@ -535,6 +559,11 @@ private static function has_valid_request_signature() { * @return bool True if WooPay is enabled, false otherwise. */ private static function is_woopay_enabled(): bool { + // There were previously instances of this function being called too early. While those should be resolved, adding this defensive check as well. + if ( ! class_exists( WC_Payments_Features::class ) || ! class_exists( WC_Payments::class ) || is_null( WC_Payments::get_gateway() ) ) { + return false; + } + return WC_Payments_Features::is_woopay_eligible() && 'yes' === WC_Payments::get_gateway()->get_option( 'platform_checkout', 'no' ); } @@ -602,5 +631,4 @@ private static function get_formatted_custom_message() { return str_replace( array_keys( $replacement_map ), array_values( $replacement_map ), $custom_message ); } - } diff --git a/package-lock.json b/package-lock.json index fcc0dc2f024..ac730941806 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "6.4.2", + "version": "6.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "6.4.2", + "version": "6.5.0", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index f3c983d27ef..ff61db2c8d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "6.4.2", + "version": "6.5.0", "main": "webpack.config.js", "author": "Automattic", "license": "GPL-3.0-or-later", @@ -64,7 +64,8 @@ "tube:stop": "./docker/bin/jt/tunnel.sh break", "psalm": "./bin/run-psalm.sh", "xdebug:toggle": "docker-compose exec -u root wordpress /var/www/html/wp-content/plugins/woocommerce-payments/bin/xdebug-toggle.sh", - "changelog": "./vendor/bin/changelogger add" + "changelog": "./vendor/bin/changelogger add", + "cli": "./bin/cli.sh" }, "dependencies": { "@automattic/interpolate-components": "1.2.1", diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 75fa2eac0f1..07c08ef26c9 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -88,4 +88,16 @@ <code>CheckoutSchema</code> </UndefinedClass> </file> + <file src="includes/admin/class-wc-rest-payments-settings-controller.php"> + <MissingDependency occurrences="8"> + <code>WC_Payments_Subscriptions::get_stripe_billing_migrator()</code> + <code>$stripe_billing_migrator</code> + <code>$stripe_billing_migrator</code> + <code>$stripe_billing_migrator</code> + <code>WC_Payments_Subscriptions::get_stripe_billing_migrator()</code> + <code>$stripe_billing_migrator</code> + <code>$stripe_billing_migrator</code> + <code>$stripe_billing_migrator</code> + </MissingDependency> + </file> </files> diff --git a/readme.txt b/readme.txt index 3cfeefbc70b..9ab74e5a082 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: payment gateway, payment, apple pay, credit card, google pay, woocommerce Requires at least: 6.0 Tested up to: 6.2 Requires PHP: 7.3 -Stable tag: 6.4.2 +Stable tag: 6.5.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -22,13 +22,13 @@ See payments, track cash flow into your bank account, manage refunds, and stay o Features previously only available on your payment provider’s website are now part of your store’s **integrated payments dashboard**. This enables you to: -- View the details of [payments, refunds, and other transactions](https://woocommerce.com/document/woocommerce-payments/managing-money/). -- View and respond to [disputes and chargebacks](https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/managing-disputes-with-woocommerce-payments/). -- [Track deposits](https://woocommerce.com/document/woocommerce-payments/deposits/) into your bank account or debit card. +- View the details of [payments, refunds, and other transactions](https://woocommerce.com/document/woopayments/managing-money/). +- View and respond to [disputes and chargebacks](https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/). +- [Track deposits](https://woocommerce.com/document/woopayments/deposits/) into your bank account or debit card. **Pay as you go** -WooPayments is **free to install**, with **no setup fees or monthly fees**. Pay-as-you-go fees start at 2.9% + $0.30 per transaction for U.S.-issued cards. [Read more about transaction fees](https://woocommerce.com/document/woocommerce-payments/fees-and-debits/fees/). +WooPayments is **free to install**, with **no setup fees or monthly fees**. Pay-as-you-go fees start at 2.9% + $0.30 per transaction for U.S.-issued cards. [Read more about transaction fees](https://woocommerce.com/document/woopayments/fees-and-debits/fees/). **Supported by the WooCommerce team** @@ -44,7 +44,7 @@ Our global support team is available to answer questions you may have about WooP = Try it now = -To try WooPayments (previously WooCommerce Payments) on your store, simply [install it](https://wordpress.org/plugins/woocommerce-payments/#installation) and follow the prompts. Please see our [Startup Guide](https://woocommerce.com/document/woocommerce-payments/startup-guide/) for a full walkthrough of the process. +To try WooPayments (previously WooCommerce Payments) on your store, simply [install it](https://wordpress.org/plugins/woocommerce-payments/#installation) and follow the prompts. Please see our [Startup Guide](https://woocommerce.com/document/woopayments/startup-guide/) for a full walkthrough of the process. WooPayments has experimental support for the Checkout block from [WooCommerce Blocks](https://wordpress.org/plugins/woo-gutenberg-products-block/). Please check the [FAQ section](#faq) for more information. @@ -56,7 +56,7 @@ Install and activate the WooCommerce and WooPayments plugins, if you haven't alr = What countries and currencies are supported? = -If you are an individual or business based in [one of these countries](https://woocommerce.com/document/woocommerce-payments/compatibility/countries/#supported-countries), you can sign-up with WooPayments. After completing sign up, you can accept payments from customers anywhere in the world. +If you are an individual or business based in [one of these countries](https://woocommerce.com/document/woopayments/compatibility/countries/#supported-countries), you can sign-up with WooPayments. After completing sign up, you can accept payments from customers anywhere in the world. We are actively planning to expand into additional countries based on your interest. Let us know where you would like to [see WooPayments launch next](https://woocommerce.com/payments/#request-invite). @@ -66,15 +66,15 @@ WooPayments uses the WordPress.com connection to authenticate each request, conn = How do I set up a store for a client? = -If you are a developer or agency setting up a site for a client, please see [this page](https://woocommerce.com/document/woocommerce-payments/account-management/developer-or-agency-setup/) of our documentation for some tips on how to install WooPayments on client sites. +If you are a developer or agency setting up a site for a client, please see [this page](https://woocommerce.com/document/woopayments/account-management/developer-or-agency-setup/) of our documentation for some tips on how to install WooPayments on client sites. = How is WooPayments related to Stripe? = -WooPayments is built in partnership with Stripe [Stripe](https://stripe.com/). When you sign up for WooPayments, your personal and business information is verified with Stripe and stored in an account connected to the WooPayments service. This account is then used in the background for managing your business account information and activity via WooPayments. [Learn more](https://woocommerce.com/document/woocommerce-payments/account-management/partnership-with-stripe/). +WooPayments is built in partnership with Stripe [Stripe](https://stripe.com/). When you sign up for WooPayments, your personal and business information is verified with Stripe and stored in an account connected to the WooPayments service. This account is then used in the background for managing your business account information and activity via WooPayments. [Learn more](https://woocommerce.com/document/woopayments/account-management/partnership-with-stripe/). = Are there Terms of Service and data usage policies? = -You can read our Terms of Service and other policies [here](https://woocommerce.com/document/woocommerce-payments/our-policies/). +You can read our Terms of Service and other policies [here](https://woocommerce.com/document/woopayments/our-policies/). = How does the Checkout block work? = @@ -94,6 +94,72 @@ Please note that our support for the checkout block is still experimental and th == Changelog == += 6.5.0 - 2023-09-21 = +* Add - Add a new task prompt to set up APMs after onboarding. Fixed an issue where a notice would show up in some unintended circumstances on the APM setup. +* Add - Add an option on the Settings screen to enable merchants to migrate their Stripe Billing subscriptions to on-site billing. +* Add - Added additional meta data to payment requests +* Add - Add onboarding task incentive badge. +* Add - Add payment request class for loading, sanitizing, and escaping data (reengineering payment process) +* Add - Add the express button on the pay for order page +* Add - add WooPay checkout appearance documentation link +* Add - Fall back to site logo when a custom WooPay logo has not been defined +* Add - Introduce a new setting that enables store to opt into Subscription off-site billing via Stripe Billing. +* Add - Load payment methods through the request class (re-engineering payment process). +* Add - Record the source (Woo Subscriptions or WCPay Subscriptions) when a Stripe Billing subscription is created. +* Add - Record the subscriptions environment context in transaction meta when Stripe Billing payments are handled. +* Add - Redirect back to the pay-for-order page when it is pay-for-order order +* Add - Support kanji and kana statement descriptors for Japanese merchants +* Add - Warn about dev mode enabled on new onboarding flow choice +* Fix - Allow request classes to be extended more than once. +* Fix - Avoid empty fields in new onboarding flow +* Fix - Corrected an issue causing incorrect responses at the cancel authorization API endpoint. +* Fix - Disable automatic currency switching and switcher widgets on pay_for_order page. +* Fix - Ensure the shipping phone number field is copied to subscriptions and their orders when copying address meta. +* Fix - Ensure the Stripe Billing subscription is cancelled when the subscription is changed from WooPayments to another payment method. +* Fix - express checkout links UI consistency & area increase +* Fix - fix: save platform checkout info on blocks +* Fix - fix checkout appearance width +* Fix - Fix Currency Switcher Block flag rendering on Windows platform. +* Fix - Fix deprecation warnings on blocks checkout. +* Fix - Fix double indicators showing under Payments tab +* Fix - Fixes the currency formatting for AED and SAR currencies. +* Fix - Fix init WooPay and empty cart error +* Fix - Fix Multi-currency exchange rate date format when using custom date or time settings. +* Fix - Fix Multicurrency widget error on post/page edit screen +* Fix - Fix single currency manual rate save producing error when no changes are made +* Fix - Fix the way request params are loaded between parent and child classes. +* Fix - Fix WooPay Session Handler in Store API requests. +* Fix - Improve escaping around attributes. +* Fix - Increase admin enqueue scripts priority to avoid compatibility issues with WooCommerce Beta Tester plugin. +* Fix - Modify title in task to continue with onboarding +* Fix - Prevent WooPay-related implementation to modify non-WooPay-specific webhooks by changing their data. +* Fix - Refactor Woo Subscriptions compatibility to fix currency being able to be updated during renewals, resubscribes, or switches. +* Fix - Update inbox note logic to prevent prompt to set up payment methods from showing on not fully onboarded account. +* Update - Add notice for legacy UPE users about deferred UPE upcoming, and adjust wording for non-UPE users +* Update - Disable refund button on order edit page when there is active or lost dispute. +* Update - Enhanced Analytics SQL, added unit test for has_multi_currency_orders(). Improved code quality and test coverage. +* Update - Improved `get_all_customer_currencies` method to retrieve existing order currencies faster. +* Update - Improve the transaction details redirect user-experience by using client-side routing. +* Update - Temporarily disable saving SEPA +* Update - Update Multi-currency documentation links. +* Update - Update outdated public documentation links on WooCommerce.com +* Update - Update Tooltip component on ConvertedAmount. +* Update - When HPOS is disabled, fetch subscriptions by customer_id using the user's subscription cache to improve performance. +* Dev - Adding factor flags to control when to enter the new payment process. +* Dev - Adding issuer evidence to dispute details. Hidden behind a feature flag +* Dev - Comment: Update GH workflows to use PHP version from plugin file. +* Dev - Comment: Update occurence of all ubuntu versions to ubuntu-latest +* Dev - Deprecated the 'woocommerce_subscriptions_not_found_label' filter. +* Dev - Fix payment context and subscription payment metadata stored on subscription recurring transactions. +* Dev - Fix Tracks conditions +* Dev - Migrate DetailsLink component to TypeScript to improve code quality +* Dev - Migrate link-item.js to typescript +* Dev - Migrate woopay-item to typescript +* Dev - Remove reference to old experiment. +* Dev - Update Base_Constant to return the singleton object for same static calls. +* Dev - Updated subscriptions-core to 6.2.0 +* Dev - Update the name of the A/B experiment on new onboarding. + = 6.4.2 - 2023-09-14 = * Fix - Fix an error in the checkout when Afterpay is selected as payment method. diff --git a/src/Internal/DependencyManagement/ServiceProvider/PaymentsServiceProvider.php b/src/Internal/DependencyManagement/ServiceProvider/PaymentsServiceProvider.php index 6b1a163b1b0..360d5364641 100644 --- a/src/Internal/DependencyManagement/ServiceProvider/PaymentsServiceProvider.php +++ b/src/Internal/DependencyManagement/ServiceProvider/PaymentsServiceProvider.php @@ -9,7 +9,9 @@ use Automattic\WooCommerce\Utilities\PluginUtil; use WCPay\Core\Mode; +use WCPay\Database_Cache; use WCPay\Internal\DependencyManagement\AbstractServiceProvider; +use WCPay\Internal\Payment\Router; use WCPay\Internal\Service\PaymentProcessingService; use WCPay\Internal\Service\ExampleService; use WCPay\Internal\Service\ExampleServiceWithDependencies; @@ -25,6 +27,7 @@ class PaymentsServiceProvider extends AbstractServiceProvider { */ protected $provides = [ PaymentProcessingService::class, + Router::class, ExampleService::class, ExampleServiceWithDependencies::class, ]; @@ -37,6 +40,9 @@ public function register(): void { $container->addShared( PaymentProcessingService::class ); + $container->addShared( Router::class ) + ->addArgument( Database_Cache::class ); + $container->addShared( ExampleService::class ); $container->addShared( ExampleServiceWithDependencies::class ) ->addArgument( ExampleService::class ) diff --git a/src/Internal/Payment/Factor.php b/src/Internal/Payment/Factor.php new file mode 100644 index 00000000000..594683f67a4 --- /dev/null +++ b/src/Internal/Payment/Factor.php @@ -0,0 +1,134 @@ +<?php +/** + * Class Factor + * + * @package WooCommerce\Payments + */ + +namespace WCPay\Internal\Payment; + +use WCPay\Constants\Base_Constant; + +/** + * A class for payment factors. + * + * These factors will be used to determine whether to enter the new + * payment process until it is fully developed and this class gets removed. + */ +class Factor extends Base_Constant { + /** + * Base flag, used to determine whether the new payment process + * can be entered at all, even if no other factors are present. + * + * Provided by the server, and used only within PaymentProcessingService. + * There is no need to provide it to `should_use_new_payment_process`. + */ + const NEW_PAYMENT_PROCESS = 'NEW_PAYMENT_PROCESS'; + + /** + * Zero payment amount, e.g. whether there is no payment to process at all. + * This excludes free subscription signups. + * Type: Condition + */ + const NO_PAYMENT = 'NO_PAYMENT'; + + /** + * Saved payment method is used. + * Type: Condition + */ + const USE_SAVED_PM = 'USE_SAVED_PM'; + + /** + * Requirement to save the payment method (choice or subscriptions). + * Type: Condition + */ + const SAVE_PM = 'SAVE_PM'; + + /** + * The order includes a subscription (sign-up). + * Type: Condition + */ + const SUBSCRIPTION_SIGNUP = 'SUBSCRIPTION_SIGNUP'; + + /** + * Subscription renewal entry point. + * Type: Entry point + */ + const SUBSCRIPTION_RENEWAL = 'SUBSCRIPTION_RENEWAL'; + + /** + * The 3DS/SCA post-authentication process is a separate entry point, not a flow. + * Type: Entry point + */ + const POST_AUTHENTICATION = 'POST_AUTHENTICATION'; + + /** + * WooPay: When enabled, it can overwrite the payment method during checkout. We could disable routing when enabled. + * Type: Condition + */ + const WOOPAY_ENABLED = 'WOOPAY_ENABLED'; + + /** + * WooPay: WooPay payments (already created intents). + * Type: Condition + */ + const WOOPAY_PAYMENT = 'WOOPAY_PAYMENT'; + + /** + * WCPay Subs are working through Subscriptions code, and can be considered together with renewals. + * Type: Entry point + */ + const WCPAY_SUBSCRIPTION_SIGNUP = 'WCPAY_SUBSCRIPTION_SIGNUP'; + + /** + * IPP capture (completion). + * Currently, it’s mostly independent of the payment process, but could be another entry point, starting with a specific state. + * Type: Entry point + */ + const IPP_CAPTURE = 'IPP_CAPTURE'; + + /** + * Stripe Link only works with UPE for now. + * So until we (potentially) get to implement UPE, it cannot be a part of the new payment process. + * Type: Condition + */ + const STRIPE_LINK = 'STRIPE_LINK'; + + /** + * Deferred UPE requires very little extra code (for both card and LPMs), but thorough testing. + * Will become a condition, once there is the one true gateway. + * Type: Entry point + */ + const DEFERRED_INTENT_SPLIT_UPE = 'DEFERRED_INTENT_SPLIT_UPE'; + + /** + * Payment request buttons (Google Pay and Apple Pay) + * Type: Entry point + */ + const PAYMENT_REQUEST = 'PAYMENT_REQUEST'; + + /** + * Returns all possible factors. + * + * @psalm-suppress MissingThrowsDocblock + * @return Factor[] + */ + public static function get_all_factors() { + return [ + static::NEW_PAYMENT_PROCESS(), + static::NO_PAYMENT(), + static::USE_SAVED_PM(), + static::SAVE_PM(), + static::SUBSCRIPTION_SIGNUP(), + static::SUBSCRIPTION_RENEWAL(), + static::POST_AUTHENTICATION(), + static::WOOPAY_ENABLED(), + static::WOOPAY_PAYMENT(), + static::WCPAY_SUBSCRIPTION_SIGNUP(), + static::IPP_CAPTURE(), + static::STRIPE_LINK(), + static::DEFERRED_INTENT_SPLIT_UPE(), + static::PAYMENT_REQUEST(), + ]; + } +} diff --git a/src/Internal/Payment/PaymentRequest.php b/src/Internal/Payment/PaymentRequest.php new file mode 100644 index 00000000000..838ae8bb8e5 --- /dev/null +++ b/src/Internal/Payment/PaymentRequest.php @@ -0,0 +1,147 @@ +<?php +/** + * Class PaymentRequest + * + * @package WooCommerce\Payments + */ + +namespace WCPay\Internal\Payment; + +use WC_Payment_Gateway_WCPay; +use WC_Payment_Token; +use WC_Payment_Tokens; +use WCPay\Internal\Payment\PaymentMethod\NewPaymentMethod; +use WCPay\Internal\Payment\PaymentMethod\PaymentMethodInterface; +use WCPay\Internal\Payment\PaymentMethod\SavedPaymentMethod; +use WCPay\Internal\Proxy\LegacyProxy; + +/** + * Class for loading, sanitizing, and escaping data from payment requests. + */ +class PaymentRequest { + /** + * Request data. + * + * @var array + */ + private $request; + + /** + * Legacy proxy. + * + * @var LegacyProxy + */ + private $legacy_proxy; + + /** + * Extract information from request data. + * + * @param LegacyProxy $legacy_proxy Legacy proxy. + * @param array|null $request Request data, this can be $_POST, or WP_REST_Request::get_params(). + */ + public function __construct( LegacyProxy $legacy_proxy, array $request = null ) { + $this->legacy_proxy = $legacy_proxy; + // phpcs:ignore WordPress.Security.NonceVerification.Missing + $this->request = $request ?? $_POST; + } + + /** + * Get the fraud prevention token from the request. + * + * @return string|null + */ + public function get_fraud_prevention_token(): ?string { + return isset( $this->request['wcpay-fraud-prevention-token'] ) + ? sanitize_text_field( wp_unslash( ( $this->request['wcpay-fraud-prevention-token'] ) ) ) + : null; + } + + /** + * Check if the request is a WooPay preflight check. + * + * @return bool + */ + public function is_woopay_preflight_check(): bool { + return isset( $this->request['is-woopay-preflight-check'] ); + } + + /** + * Gets the provided WooPay intent ID from POST, if any. + * + * @return ?string + */ + public function get_woopay_intent_id(): ?string { + return isset( $this->request['platform-checkout-intent'] ) + ? sanitize_text_field( wp_unslash( ( $this->request['platform-checkout-intent'] ) ) ) + : null; + } + + /** + * Gets the ID of an order from the request. + * + * @return int|null + */ + public function get_order_id(): ?int { + return isset( $this->request['order_id'] ) ? absint( $this->request['order_id'] ) : null; + } + + /** + * Gets intent ID if any. + * + * @return string|null + */ + public function get_intent_id(): ?string { + return isset( $this->request['intent_id'] ) + ? sanitize_text_field( wp_unslash( ( $this->request['intent_id'] ) ) ) + : null; + } + + /** + * Gets the ID of the provided payment method. + * + * @return string|null + */ + public function get_payment_method_id(): ?string { + return isset( $this->request['payment_method_id'] ) + ? sanitize_text_field( wp_unslash( ( $this->request['payment_method_id'] ) ) ) + : null; + } + + /** + * Gets payment method object from request. + * + * @throws PaymentRequestException + */ + public function get_payment_method(): PaymentMethodInterface { + $request = $this->request; + + $is_woopayment_selected = isset( $request['payment_method'] ) && WC_Payment_Gateway_WCPay::GATEWAY_ID === $request['payment_method']; + if ( ! $is_woopayment_selected ) { + throw new PaymentRequestException( __( 'WooPayments is not used during checkout.', 'woocommerce-payments' ) ); + } + + $token_request_key = 'wc-' . WC_Payment_Gateway_WCPay::GATEWAY_ID . '-payment-token'; + if ( isset( $request[ $token_request_key ] ) && 'new' !== $request[ $token_request_key ] ) { + $token_id = absint( wp_unslash( $request [ $token_request_key ] ) ); + + /** + * Retrieved token object. + * + * @var null| WC_Payment_Token $token + */ + $token = $this->legacy_proxy->call_static( WC_Payment_Tokens::class, 'get', $token_id ); + + if ( is_null( $token ) ) { + throw new PaymentRequestException( __( 'Invalid saved payment method (token) ID.', 'woocommerce-payments' ) ); + } + return new SavedPaymentMethod( $token ); + } + + if ( ! empty( $request['wcpay-payment-method'] ) ) { + $payment_method = sanitize_text_field( wp_unslash( $request['wcpay-payment-method'] ) ); + return new NewPaymentMethod( $payment_method ); + } + + throw new PaymentRequestException( __( 'No valid payment method was selected.', 'woocommerce-payments' ) ); + } +} diff --git a/src/Internal/Payment/PaymentRequestException.php b/src/Internal/Payment/PaymentRequestException.php new file mode 100644 index 00000000000..d0583457340 --- /dev/null +++ b/src/Internal/Payment/PaymentRequestException.php @@ -0,0 +1,14 @@ +<?php +/** + * Class PaymentRequestException + * + * @package WooCommerce\Payments + */ + +namespace WCPay\Internal\Payment; + +/** + * Exception thrown when a payment request is invalid. + */ +class PaymentRequestException extends \Exception { +} diff --git a/src/Internal/Payment/Router.php b/src/Internal/Payment/Router.php new file mode 100644 index 00000000000..abe73cd1d7a --- /dev/null +++ b/src/Internal/Payment/Router.php @@ -0,0 +1,115 @@ +<?php +/** + * Class Router + * + * @package WooCommerce\Payments + */ + +namespace WCPay\Internal\Payment; + +use WCPay\Core\Server\Request\Get_Payment_Process_Factors; +use WCPay\Database_Cache; +use WCPay\Exceptions\API_Exception; +use WCPay\Internal\Payment\Factor; + +/** + * Until the new payment process is fully developed, and the legacy + * process is gone, the new process is managed as a feature behind a router. + * + * This class will be removed once the new process is the default + * option for all payments. + */ +class Router { + /** + * Database cache. + * + * @var Database_Cache + */ + protected $database_cache; + + /** + * Class constructor, receiving dependencies. + * + * @param Database_Cache $database_cache Database cache. + */ + public function __construct( Database_Cache $database_cache ) { + $this->database_cache = $database_cache; + } + + /** + * Checks whether a given payment should use the new payment process. + * + * @param Factor[] $factors Factors, describing the type and conditions of the payment. + * @return bool + * @psalm-suppress MissingThrowsDocblock + */ + public function should_use_new_payment_process( array $factors ): bool { + $allowed_factors = $this->get_allowed_factors(); + + foreach ( $factors as $present_factor ) { + if ( ! in_array( $present_factor, $allowed_factors, true ) ) { + return false; + } + } + + return true; + } + + /** + * Returns all factors, which can be handled by the new payment process. + * + * @return Factor[] + */ + public function get_allowed_factors() { + // Might be false if loading failed. + $cached = $this->get_cached_factors(); + $all_factors = is_array( $cached ) ? $cached : []; + $allowed = []; + + foreach ( ( $all_factors ?? [] ) as $key => $enabled ) { + if ( $enabled ) { + $allowed[] = Factor::$key(); + } + } + + $allowed = apply_filters( 'wcpay_new_payment_process_enabled_factors', $allowed ); + return $allowed; + } + + /** + * Checks if cached data is valid. + * + * @psalm-suppress MissingThrowsDocblock + * @param mixed $cache The cached data. + * @return bool + */ + public function is_valid_cache( $cache ): bool { + return is_array( $cache ) && isset( $cache[ Factor::NEW_PAYMENT_PROCESS()->get_value() ] ); + } + + /** + * Gets and chaches all factors, which can be handled by the new payment process. + * + * @param bool $force_refresh Forces data to be fetched from the server, rather than using the cache. + * @return array Factors, or an empty array. + */ + private function get_cached_factors( bool $force_refresh = false ) { + $factors = $this->database_cache->get_or_add( + Database_Cache::PAYMENT_PROCESS_FACTORS_KEY, + function () { + try { + $request = Get_Payment_Process_Factors::create(); + $response = $request->send( 'wcpay_get_payment_process_factors' ); + return $response->to_array(); + } catch ( API_Exception $e ) { + // Return false to signal retrieval error. + return false; + } + }, + [ $this, 'is_valid_cache' ], + $force_refresh + ); + + return $factors ?? []; + } +} diff --git a/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php b/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php index 680bc81a809..e5f50a01baa 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php @@ -1357,6 +1357,207 @@ public function test_create_terminal_intent_invalid_capture_method() { $this->assertSame( 500, $data['status'] ); } + public function test_cancel_authorization_success() { + $order = $this->create_mock_order(); + $request = new WP_REST_Request( 'POST' ); + $request->set_url_params( + [ + 'order_id' => $order->get_id(), + ] + ); + $request->set_body_params( + [ + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'cancel_authorization' ) + ->with( $this->isInstanceOf( WC_Order::class ) ) + ->willReturn( + [ + 'status' => Intent_Status::CANCELED, + 'id' => $this->mock_intent_id, + ] + ); + + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'id' => $this->mock_intent_id, + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'metadata' => [ + 'order_id' => $order->get_id(), + ], + ] + ); + $wcpay_request = $this->mock_wcpay_request( Get_Intention::class, 1, $this->mock_intent_id ); + + $wcpay_request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + $response = $this->controller->cancel_authorization( $request ); + + $response_data = $response->get_data(); + + $this->assertEquals( 200, $response->status ); + $this->assertEquals( + [ + 'status' => Intent_Status::CANCELED, + 'id' => $this->mock_intent_id, + ], + $response_data + ); + } + public function test_cancel_authorization_will_fail_if_order_is_incorrect() { + $order = $this->create_mock_order(); + $request = new WP_REST_Request( 'POST' ); + $request->set_url_params( + [ + 'order_id' => $order->get_id() + 1, + ] + ); + $request->set_body_params( + [ + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $this->mock_gateway + ->expects( $this->never() ) + ->method( 'cancel_authorization' ); + + $this->mock_wcpay_request( Get_Intention::class, 0 ); + + $response = $this->controller->cancel_authorization( $request ); + + $this->assertInstanceOf( 'WP_Error', $response ); + $data = $response->get_error_data(); + $this->assertArrayHasKey( 'status', $data ); + $this->assertSame( 404, $data['status'] ); + } + public function test_cancel_authorization_will_fail_if_order_is_refunded() { + $order = $this->create_mock_order(); + wc_create_refund( + [ + 'order_id' => $order->get_id(), + 'amount' => 10.0, + 'line_items' => [], + ] + ); + $request = new WP_REST_Request( 'POST' ); + $request->set_url_params( + [ + 'order_id' => $order->get_id(), + ] + ); + $request->set_body_params( + [ + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $this->mock_gateway + ->expects( $this->never() ) + ->method( 'cancel_authorization' ); + + $this->mock_wcpay_request( Get_Intention::class, 0 ); + + $response = $this->controller->cancel_authorization( $request ); + + $this->assertInstanceOf( 'WP_Error', $response ); + $data = $response->get_error_data(); + $this->assertArrayHasKey( 'status', $data ); + $this->assertSame( 400, $data['status'] ); + } + public function test_cancel_authorization_will_fail_if_order_does_not_match_with_payment_intent() { + $order = $this->create_mock_order(); + $request = new WP_REST_Request( 'POST' ); + $request->set_url_params( + [ + 'order_id' => $order->get_id(), + ] + ); + $request->set_body_params( + [ + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'id' => $this->mock_intent_id, + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'metadata' => [ + 'order_id' => $order->get_id() + 1, + ], + ] + ); + $wcpay_request = $this->mock_wcpay_request( Get_Intention::class, 1, $this->mock_intent_id ); + + $wcpay_request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + + $this->mock_gateway + ->expects( $this->never() ) + ->method( 'cancel_authorization' ); + + $response = $this->controller->cancel_authorization( $request ); + + $this->assertInstanceOf( 'WP_Error', $response ); + $data = $response->get_error_data(); + $this->assertArrayHasKey( 'status', $data ); + $this->assertSame( 409, $data['status'] ); + } + + public function test_cancel_authorization_will_fail_if_gateway_fails_to_cancel_authorization() { + $order = $this->create_mock_order(); + $request = new WP_REST_Request( 'POST' ); + $request->set_url_params( + [ + 'order_id' => $order->get_id(), + ] + ); + $request->set_body_params( + [ + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'id' => $this->mock_intent_id, + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'metadata' => [ + 'order_id' => $order->get_id(), + ], + ] + ); + $wcpay_request = $this->mock_wcpay_request( Get_Intention::class, 1, $this->mock_intent_id ); + + $wcpay_request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + + $this->mock_gateway + ->method( 'cancel_authorization' ) + ->with( $this->isInstanceOf( WC_Order::class ) ) + ->willReturn( + [ + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'id' => $this->mock_intent_id, + ] + ); + + $response = $this->controller->cancel_authorization( $request ); + + $this->assertInstanceOf( 'WP_Error', $response ); + $data = $response->get_error_data(); + $this->assertArrayHasKey( 'status', $data ); + $this->assertSame( 502, $data['status'] ); + } + private function create_mock_order() { $charge = $this->create_charge_object(); diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php index ee1332e6c38..48ba5bcdf7d 100755 --- a/tests/unit/bootstrap.php +++ b/tests/unit/bootstrap.php @@ -116,8 +116,11 @@ function() { * * Init'ing the subscriptions-core loads all subscriptions class and hooks, which breaks existing WCPAY unit tests. * WCPAY already mocks the WC Subscriptions classes/functions it needs so there's no need to load them anyway. + * + * This function should only be used to load any mocked Subscriptions Core classes that need to be loaded before the PHPUnit FileLoader. */ function wcpay_init_subscriptions_core() { + require_once __DIR__ . '/helpers/class-wcs-helper-background-repairer.php'; } // Placeholder for the test container. @@ -139,7 +142,11 @@ function wcpay_get_test_container() { $container = $GLOBALS['wcpay_container'] ?? null; if ( ! $container instanceof Container ) { - throw new Exception( 'Tests require the WCPay dependency container to be set up.' ); + if ( is_null( $container ) ) { + $container = wcpay_get_container(); + } else { + throw new Exception( 'Tests require the WCPay dependency container to be set up.' ); + } } // Load the property through reflection. diff --git a/tests/unit/core/server/request/test-class-core-request.php b/tests/unit/core/server/request/test-class-core-request.php new file mode 100644 index 00000000000..8f7e48d016b --- /dev/null +++ b/tests/unit/core/server/request/test-class-core-request.php @@ -0,0 +1,137 @@ +<?php +/** + * Class WCPay_Core_Request_Test + * + * @package WooCommerce\Payments\Tests + */ + +use WCPay\Core\Server\Request; +use WCPay\Core\Server\Request\Paginated; +use WCPay\Core\Server\Request\List_Transactions; + +// phpcs:disable +class My_Request extends Request { + const DEFAULT_PARAMS = [ + 'default_1' => 1, + ]; + + public function get_api(): string { + return WC_Payments_API_Client::INTENTIONS_API; + } + + public function get_method(): string { + return 'POST'; + } + + public function set_param_1( int $value ) { + $this->set_param( 'param_1', $value ); + } +} +class WooPay_Request extends My_Request { + const DEFAULT_PARAMS = [ + 'default_2' => 2, + ]; + + public function set_param_2( int $value ) { + $this->set_param( 'param_2', $value ); + } +} +class ThirdParty_Request extends My_Request { + const DEFAULT_PARAMS = [ + 'default_3' => 3, + ]; + + public function set_param_3( int $value ) { + $this->set_param( 'param_3', $value ); + } +} +class Another_ThirdParty_Request extends WooPay_Request { + const DEFAULT_PARAMS = [ + 'default_4' => 4, + ]; + + public function set_param_4( int $value ) { + $this->set_param( 'param_4', $value ); + } +} +// phpcs:enable +// phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound + +/** + * WCPay\Core\Server\Capture_Intention_Test unit tests. + */ +class WCPay_Core_Request_Test extends WCPAY_UnitTestCase { + /** + * Tests the most basic function of `traverse_class_constants`, + * which is to go though all classes in the tree, and return a constant in the right order. + */ + public function test_traverse_class_constants() { + $expected = []; + $tree = [ + Request::class, + Paginated::class, + List_Transactions::class, + ]; + foreach ( $tree as $class_name ) { + $expected = array_merge( $expected, constant( $class_name . '::DEFAULT_PARAMS' ) ); + } + + $result = List_Transactions::traverse_class_constants( 'DEFAULT_PARAMS' ); + $this->assertSame( $expected, $result ); + } + + /** + * Ensures that `::extend` works with any class, which extends the + * base request (where `apply_filters` is called) directly or indirectly. + */ + public function test_extension_by_multiple_classes() { + $hook = 'some_request_class'; + $request = My_Request::create(); + $request->set_param_1( 1 ); + + add_filter( + $hook, + function( $request ) { + $modified = WooPay_Request::extend( $request ); + $modified->set_param_2( 2 ); + return $modified; + } + ); + + add_filter( + $hook, + function( $request ) { + $modified = ThirdParty_Request::extend( $request ); + $modified->set_param_3( 3 ); + return $modified; + } + ); + + add_filter( + $hook, + function( $request ) { + $modified = Another_ThirdParty_Request::extend( $request ); + $modified->set_param_4( 4 ); + return $modified; + } + ); + + $filtered = $request->apply_filters( $hook ); + $result = $filtered->get_params(); + + // Assert: It's important that we got here without exceptions, but everything should be set. + $this->assertEquals( + [ + 'param_1' => 1, + 'param_2' => 2, + 'param_3' => 3, + 'param_4' => 4, + 'default_1' => 1, + 'default_2' => 2, + 'default_3' => 3, + 'default_4' => 4, + ], + $result + ); + } +} diff --git a/tests/unit/fraud-prevention/test-class-order-fraud-and-risk-meta-box.php b/tests/unit/fraud-prevention/test-class-order-fraud-and-risk-meta-box.php index b027ab40280..0ec12e225e1 100644 --- a/tests/unit/fraud-prevention/test-class-order-fraud-and-risk-meta-box.php +++ b/tests/unit/fraud-prevention/test-class-order-fraud-and-risk-meta-box.php @@ -195,17 +195,17 @@ public function display_order_fraud_and_risk_meta_box_message_not_card_provider( 'simulate legacy UPE Popular payment methods' => [ 'payment_method_id' => 'woocommerce_payments', 'payment_method_title' => 'Popular payment methods', - 'expected_output' => '<p>Risk filtering is only available for orders processed using credit cards with WooPayments.</p><a href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>', + 'expected_output' => '<p>Risk filtering is only available for orders processed using credit cards with WooPayments.</p><a href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>', ], 'simulate legacy UPE Bancontact' => [ 'payment_method_id' => 'woocommerce_payments', 'payment_method_title' => 'Bancontact', - 'expected_output' => '<p>Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.</p><a href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>', + 'expected_output' => '<p>Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.</p><a href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>', ], 'simulate split UPE Bancontact' => [ 'payment_method_id' => 'woocommerce_payments_bancontact', 'payment_method_title' => 'Bancontact', - 'expected_output' => '<p>Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.</p><a href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>', + 'expected_output' => '<p>Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.</p><a href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>', ], ]; } @@ -235,7 +235,7 @@ public function test_display_order_fraud_and_risk_meta_box_message_not_wcpay() { $this->order_fraud_and_risk_meta_box->display_order_fraud_and_risk_meta_box_message( $this->order ); // Assert: Check to make sure the expected string has been output. - $this->expectOutputString( '<p>Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Direct bank transfer.</p><a href="https://woocommerce.com/document/woocommerce-payments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>' ); + $this->expectOutputString( '<p>Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Direct bank transfer.</p><a href="https://woocommerce.com/document/woopayments/fraud-and-disputes/fraud-protection/?status_is=fraud-meta-box-not-wcpay-learn-more" target="_blank" rel="noopener noreferrer">Learn more</a>' ); } public function test_display_order_fraud_and_risk_meta_box_message_default() { diff --git a/tests/unit/helpers/class-wc-helper-subscription.php b/tests/unit/helpers/class-wc-helper-subscription.php index 5bb7a3a46a6..2def6724841 100644 --- a/tests/unit/helpers/class-wc-helper-subscription.php +++ b/tests/unit/helpers/class-wc-helper-subscription.php @@ -120,6 +120,13 @@ class WC_Subscription extends WC_Mock_WC_Data { */ public $has_product = false; + /** + * The customer ID for the subscription. + * + * @var null|int + */ + public $customer_id = null; + /** * A helper function for handling function calls not yet implimented on this helper. * @@ -214,6 +221,10 @@ public function get_currency() { return $this->currency; } + public function set_currency( $currency = 'USD' ) { + $this->currency = $currency; + } + public function add_order_note( $note = '' ) { // do nothing. } @@ -257,4 +268,13 @@ public function set_has_product( bool $has_product ) { public function has_product() { return $this->has_product; } + + public function get_customer_id() { + return $this->customer_id ?? get_current_user_id(); + } + + public function set_customer_id( $customer_id = null ) { + $this->customer_id = $customer_id ?? get_current_user_id(); + + } } diff --git a/tests/unit/helpers/class-wc-helper-subscriptions.php b/tests/unit/helpers/class-wc-helper-subscriptions.php index fa40ca58aeb..3d361a6446d 100644 --- a/tests/unit/helpers/class-wc-helper-subscriptions.php +++ b/tests/unit/helpers/class-wc-helper-subscriptions.php @@ -83,6 +83,13 @@ function wcs_order_contains_renewal() { return ( WC_Subscriptions::$wcs_order_contains_renewal )(); } +function wcs_get_orders_with_meta_query( $args ) { + if ( ! WC_Subscriptions::$wcs_get_orders_with_meta_query ) { + return []; + } + return ( WC_Subscriptions::$wcs_get_orders_with_meta_query )( $args ); +} + /** * Class WC_Subscriptions. * @@ -166,6 +173,13 @@ class WC_Subscriptions { */ public static $wcs_create_renewal_order = null; + /** + * wcs_get_orders_with_meta_query mock. + * + * @var function + */ + public static $wcs_get_orders_with_meta_query = null; + /** * wcs_order_contains_renewal mock. * diff --git a/tests/unit/helpers/class-wcs-helper-background-repairer.php b/tests/unit/helpers/class-wcs-helper-background-repairer.php new file mode 100644 index 00000000000..f97ccac6347 --- /dev/null +++ b/tests/unit/helpers/class-wcs-helper-background-repairer.php @@ -0,0 +1,22 @@ +<?php +/** + * Subscription WCS_Background_Repairer helper. + * + * @package WooCommerce\Payments\Tests + */ + +/** + * Class WCS_Background_Repairer. + * + * This helper class should ONLY be used for unit tests!. + */ +class WCS_Background_Repairer { + + public function init() { + // Do nothing. + } + + protected function unschedule_background_updates() { + // Do nothing. + } +} diff --git a/tests/unit/multi-currency/compatibility/test-class-woocommerce-subscriptions.php b/tests/unit/multi-currency/compatibility/test-class-woocommerce-subscriptions.php index b1ee4f21fb0..b7249477e0f 100644 --- a/tests/unit/multi-currency/compatibility/test-class-woocommerce-subscriptions.php +++ b/tests/unit/multi-currency/compatibility/test-class-woocommerce-subscriptions.php @@ -77,15 +77,12 @@ public function set_up() { } public function tear_down() { - // Reset cart checks so future tests can pass. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_wcs_get_order_type_cart_items( false ); + // Clear our cart on every iteration, also clears the session cart. + WC()->cart->empty_cart(); parent::tear_down(); } - /** * @dataProvider woocommerce_filter_provider */ @@ -112,123 +109,82 @@ public function woocommerce_filter_provider() { [ 'wcpay_multi_currency_override_selected_currency', 'override_selected_currency' ], [ 'wcpay_multi_currency_should_convert_product_price', 'should_convert_product_price' ], [ 'wcpay_multi_currency_should_convert_coupon_amount', 'should_convert_coupon_amount' ], - [ 'wcpay_multi_currency_should_hide_widgets', 'should_hide_widgets' ], + [ 'wcpay_multi_currency_should_disable_currency_switching', 'should_disable_currency_switching' ], ]; } - // Test should not convert the product price due to all checks return true. - public function test_get_subscription_product_price_does_not_convert_price() { - // Arrange: Create a subscription to be used. - $mock_subscription = new WC_Subscription(); - $mock_subscription->set_has_product( true ); - - // Arrange: Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Arrange: Set our mock return values. - $this->mock_utils->method( 'is_call_in_backtrace' )->willReturn( true ); - $this->mock_wcs_cart_contains_renewal( 42, 43, 44 ); - $this->mock_wcs_cart_contains_resubscribe( 42 ); - + // Will not convert the sub price due null is passed as the price. + public function test_get_subscription_product_price_does_not_convert_price_when_no_price_passed() { // Act: Attempt to convert the subscription price. - $result = $this->woocommerce_subscriptions->get_subscription_product_price( 10.0, $this->mock_product ); + $result = $this->woocommerce_subscriptions->get_subscription_product_price( null, $this->mock_product ); - // Assert: Confirm the result value is not converted. - $this->assertSame( 10.0, $result ); + // Assert: Confirm the result value is null. + $this->assertNull( $result ); } - // Test should convert product price due to all checks return false. - public function test_get_subscription_product_price_converts_price_with_all_checks_false() { - $this->mock_utils->method( 'is_call_in_backtrace' )->willReturn( false ); - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_multi_currency->method( 'get_price' )->with( 10.0, 'product' )->willReturn( 25.0 ); - $this->assertSame( 25.0, $this->woocommerce_subscriptions->get_subscription_product_price( 10.0, $this->mock_product ) ); - } - - // Test should convert product price due to the backtrace check returns true but the cart contains renewal/resubscribe return checks false. - public function test_get_subscription_product_price_converts_price_if_only_backtrace_found() { + /** + * Will not convert the sub price due to the is_call_in_backtrace calls in should_convert_product_price return true, which + * causes should_convert_product_price to return false to not convert the price. + */ + public function test_get_subscription_product_price_does_not_convert_price() { + // Arrange: Set our mock return values. $this->mock_utils - ->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) - ->with( [ 'WC_Payments_Subscription_Service->get_recurring_item_data_for_subscription' ] ) - ->willReturn( false ); + ->willReturn( true ); - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_multi_currency->method( 'get_price' )->with( 10.0, 'product' )->willReturn( 25.0 ); - $this->assertSame( 25.0, $this->woocommerce_subscriptions->get_subscription_product_price( 10.0, $this->mock_product ) ); + // Act/Assert: Confirm the result value is not converted. + $this->assertSame( 10.0, $this->woocommerce_subscriptions->get_subscription_product_price( 10.0, $this->mock_product ) ); } - // Test should convert product price due to the backtrace check returns false after the cart contains renewal check returns true. - public function test_get_subscription_product_price_converts_price_if_only_renewal_in_cart() { - // Arrange: Create a subscription to be used. - $mock_subscription = new WC_Subscription(); - $mock_subscription->set_has_product( true ); - - // Arrange: Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Arrange: Set our mock return values. - $this->mock_utils->method( 'is_call_in_backtrace' )->willReturn( false ); - $this->mock_wcs_cart_contains_renewal( 42, 43, 44 ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_multi_currency->method( 'get_price' )->with( 10.0, 'product' )->willReturn( 25.0 ); - - // Act: Attempt to convert the subscription price. - $result = $this->woocommerce_subscriptions->get_subscription_product_price( 10.0, $this->mock_product ); - - // Assert: Confirm the result value is converted. - $this->assertSame( 25.0, $result ); + // Will not convert the sub signup fee due null is passed as the fee. + public function test_get_subscription_product_signup_fee_does_not_convert_price_when_no_fee_passed() { + // Act/Assert: Confirm the result value is null. + $this->assertNull( $this->woocommerce_subscriptions->get_subscription_product_signup_fee( null, $this->mock_product ) ); } - // Test should convert product price due to the backtrace check returns false after the cart contains resubscribe check returns true. - public function test_get_subscription_product_price_converts_price_if_only_resubscribe_in_cart() { - // Arrange: Create a subscription to be used. - $mock_subscription = new WC_Subscription(); - $mock_subscription->set_has_product( true ); - - // Arrange: Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Arrange: Set our mock return values. - $this->mock_utils->method( 'is_call_in_backtrace' )->willReturn( false ); - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( 42 ); - $this->mock_multi_currency->method( 'get_price' )->with( 10.0, 'product' )->willReturn( 25.0 ); - - // Act: Attempt to convert the subscription price. - $result = $this->woocommerce_subscriptions->get_subscription_product_price( 10.0, $this->mock_product ); + // If there is no switch in the cart, then the signup fee should be converted. + public function test_get_subscription_product_signup_fee_converts_fee_when_no_switch_in_cart() { + // Arrange: Set the expectation and return for the call to get_price. + $this->mock_multi_currency + ->expects( $this->once() ) + ->method( 'get_price' ) + ->with( 10.0, 'product' ) + ->willReturn( 25.0 ); - // Assert: Confirm the result value is converted. - $this->assertSame( 25.0, $result ); + // Act/Assert: Confirm the result value is converted. + $this->assertSame( 25.0, $this->woocommerce_subscriptions->get_subscription_product_signup_fee( 10.0, $this->mock_product ) ); } // Does not convert price due to first backtrace check returns true. public function test_get_subscription_product_signup_fee_does_not_convert_price_on_first_backtrace_match() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'switch' ); + + // Arrange: Set the expectation and return for the is_call_in_backtrace call. $this->mock_utils ->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) ->with( [ 'WC_Subscriptions_Cart::set_subscription_prices_for_calculation' ] ) ->willReturn( true ); - $this->mock_wcs_get_order_type_cart_items( 42 ); + + // Arrange: Set the expectation for the call to get_price. + $this->mock_multi_currency + ->expects( $this->never() ) + ->method( 'get_price' ); + + // Act/Assert: Confirm the result value is not converted. $this->assertSame( 10.0, $this->woocommerce_subscriptions->get_subscription_product_signup_fee( 10.0, $this->mock_product ) ); } // Does not convert price due to second check with backtrace and cart item key check returns true. public function test_get_subscription_product_signup_fee_does_not_convert_price_during_proration_calculation() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'switch' ); + + // Arrange: Set our switch_cart_item property. + $this->woocommerce_subscriptions->switch_cart_item = 'abc123'; + + // Arrange: Set the expectations and returns for the is_call_in_backtrace calls. $this->mock_utils ->expects( $this->exactly( 4 ) ) ->method( 'is_call_in_backtrace' ) @@ -239,290 +195,252 @@ public function test_get_subscription_product_signup_fee_does_not_convert_price_ [ [ 'WCS_Switch_Totals_Calculator->apportion_sign_up_fees' ] ] ) ->willReturn( false, true, true, false ); - $this->mock_wcs_get_order_type_cart_items( 42 ); - $this->woocommerce_subscriptions->switch_cart_item = 'abc123'; + + // Arrange: Set the expectation for the call to get_price. + $this->mock_multi_currency + ->expects( $this->never() ) + ->method( 'get_price' ); + + // Act/Assert: Confirm the result value is not converted. $this->assertSame( 10.0, $this->woocommerce_subscriptions->get_subscription_product_signup_fee( 10.0, $this->mock_product ) ); } // Does not convert due to third check for changes in the meta data returns true. public function test_get_subscription_product_signup_fee_does_not_convert_price_when_meta_already_updated() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'switch' ); + + // Arrange: Set our switch_cart_item property. + $this->woocommerce_subscriptions->switch_cart_item = 'abc123'; + + // Arrange: Set the expectation for the call to is_call_in_backtrace and always return false. $this->mock_utils - ->expects( $this->any() ) + ->expects( $this->exactly( 2 ) ) ->method( 'is_call_in_backtrace' ) ->willReturn( false ); - $this->mock_wcs_get_order_type_cart_items( 42 ); - $this->woocommerce_subscriptions->switch_cart_item = 'abc123'; + // Arrange: Set expectations and returns for get_meta_data, get_data, and get_changes. + $this->mock_product + ->expects( $this->once() ) + ->method( 'get_meta_data' ) + ->willReturn( [ $this->mock_meta_data ] ); $this->mock_meta_data + ->expects( $this->once() ) ->method( 'get_data' ) ->willReturn( [ 'key' => '_subscription_sign_up_fee' ] ); $this->mock_meta_data + ->expects( $this->once() ) ->method( 'get_changes' ) ->willReturn( [ 1, 2 ] ); - $this->mock_product - ->method( 'get_meta_data' ) - ->willReturn( [ $this->mock_meta_data ] ); + // Arrange: Set the expectation for the call to get_price. + $this->mock_multi_currency + ->expects( $this->never() ) + ->method( 'get_price' ); + // Act/Assert: Confirm the result value is not converted. $this->assertSame( 10.0, $this->woocommerce_subscriptions->get_subscription_product_signup_fee( 10.0, $this->mock_product ) ); } - // Converts due to backtraces are not found and the check for changes in meta data returns false. - public function test_get_subscription_product_signup_fee_converts_price_when_meta_not_updated() { + // Converts price due to the switch item does not match the item being checked. + public function test_get_subscription_product_signup_fee_converts_price_when_cart_item_keys_do_not_match() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'switch' ); + + // Arrange: Set our switch_cart_item property so that it does not match what's in the cart. + $this->woocommerce_subscriptions->switch_cart_item = 'def456'; + + // Arrange: Set the expectation for the call to is_call_in_backtrace and always return false. $this->mock_utils - ->expects( $this->any() ) + ->expects( $this->exactly( 2 ) ) ->method( 'is_call_in_backtrace' ) ->willReturn( false ); - $this->mock_wcs_get_order_type_cart_items( 42 ); - $this->woocommerce_subscriptions->switch_cart_item = 'abc123'; + // Arrange: Set expectations for get_meta_data, get_data, and get_changes. + $this->mock_product + ->expects( $this->never() ) + ->method( 'get_meta_data' ); $this->mock_meta_data - ->method( 'get_data' ) - ->willReturn( [ 'key' => '_subscription_sign_up_fee' ] ); + ->expects( $this->never() ) + ->method( 'get_data' ); $this->mock_meta_data - ->method( 'get_changes' ) - ->willReturn( [] ); + ->expects( $this->never() ) + ->method( 'get_changes' ); - $this->mock_product - ->method( 'get_meta_data' ) - ->willReturn( [ $this->mock_meta_data ] ); + // Arrange: Set the expectation and return value for the call to get_price. + $this->mock_multi_currency + ->expects( $this->once() ) + ->method( 'get_price' ) + ->with( 10.0, 'product' ) + ->willReturn( 25.0 ); - $this->mock_multi_currency->method( 'get_price' )->with( 10.0, 'product' )->willReturn( 25.0 ); + // Act/Assert: Confirm the result value is converted. $this->assertSame( 25.0, $this->woocommerce_subscriptions->get_subscription_product_signup_fee( 10.0, $this->mock_product ) ); } - // Converts due to the same as above, and the cart item keys do not match. - public function test_get_subscription_product_signup_fee_converts_price_when_cart_item_keys_do_not_match() { + // Converts due to backtraces are not found and the check for changes in meta data returns false. + public function test_get_subscription_product_signup_fee_converts_price_when_meta_not_updated() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'switch' ); + + // Arrange: Set our switch_cart_item property. + $this->woocommerce_subscriptions->switch_cart_item = 'abc123'; + + // Arrange: Set the expectation for the call to is_call_in_backtrace and always return false. $this->mock_utils - ->expects( $this->any() ) + ->expects( $this->exactly( 2 ) ) ->method( 'is_call_in_backtrace' ) ->willReturn( false ); - $this->mock_wcs_get_order_type_cart_items( 42 ); - $this->woocommerce_subscriptions->switch_cart_item = 'def456'; + // Arrange: Set expectations and returns for get_meta_data and get_data. $this->mock_product + ->expects( $this->once() ) ->method( 'get_meta_data' ) + ->willReturn( [ $this->mock_meta_data ] ); + $this->mock_meta_data + ->expects( $this->once() ) + ->method( 'get_data' ) + ->willReturn( [ 'key' => '_subscription_sign_up_fee' ] ); + + // Arrange: Set expectation and return for get_changes so that it is empty. + $this->mock_meta_data + ->expects( $this->once() ) + ->method( 'get_changes' ) ->willReturn( [] ); - $this->mock_multi_currency->method( 'get_price' )->with( 10.0, 'product' )->willReturn( 25.0 ); + // Arrange: Set the expectation and return value for the call to get_price. + $this->mock_multi_currency + ->expects( $this->once() ) + ->method( 'get_price' ) + ->with( 10.0, 'product' ) + ->willReturn( 25.0 ); + + // Act/Assert: Confirm the result value is converted. $this->assertSame( 25.0, $this->woocommerce_subscriptions->get_subscription_product_signup_fee( 10.0, $this->mock_product ) ); } public function test_maybe_disable_mixed_cart_return_no() { - $this->mock_wcs_get_order_type_cart_items( 42 ); + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'switch' ); + + // Act/Assert: 'no' should be returned due to the item in the cart is a switch. $this->assertSame( 'no', $this->woocommerce_subscriptions->maybe_disable_mixed_cart( 'yes' ) ); } public function test_maybe_disable_mixed_cart_return_yes() { - $this->mock_wcs_get_order_type_cart_items( false ); + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'renewal' ); + + // Act/Assert: 'yes' should be returned due to the item in the cart is a renewal and not a switch. $this->assertSame( 'yes', $this->woocommerce_subscriptions->maybe_disable_mixed_cart( 'yes' ) ); } - // Returns code due to code was passed. + // Returns currency code due to code was passed. public function test_override_selected_currency_return_currency_code_when_code_passed() { - // Conditions added to return EUR, but CAD should be returned at the beginning of the method. - $this->mock_wcs_cart_contains_renewal( 42, 43 ); - $this->mock_wcs_cart_contains_resubscribe( false ); - update_post_meta( 42, '_order_currency', 'EUR', true ); - $this->mock_wcs_get_order_type_cart_items( false ); + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items(); + // Arrange: Set the currency for the sub and update the cart items in the session. + $mock_subscription->set_currency( 'JPY' ); + WC()->session->set( 'cart', $cart_items ); + + // Assert: CAD should be returned since it was passed, even though there is an item in the cart. $this->assertSame( 'CAD', $this->woocommerce_subscriptions->override_selected_currency( 'CAD' ) ); } - // Returns false due to all checks return false. - public function test_override_selected_currency_return_false() { - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_wcs_get_order_type_cart_items( false ); + // Returns false due we are not adding products to the cart. + public function test_override_selected_currency_return_false_if_no_cart_items() { + // Assert: False should be received since there's nothing in the cart. $this->assertFalse( $this->woocommerce_subscriptions->override_selected_currency( false ) ); } - // Returns code due to cart contains a subscription renewal. - public function test_override_selected_currency_return_currency_code_when_renewal_in_cart() { - // Set up an order with a non-default currency. - $order = WC_Helper_Order::create_order(); - $order->set_currency( 'JPY' ); - $order->save(); - - // Mock that order has the renewal in the cart. - $this->mock_wcs_cart_contains_renewal( 42, $order->get_id() ); - - $this->assertSame( 'JPY', $this->woocommerce_subscriptions->override_selected_currency( false ) ); - } - - // Returns order currency code when order awaiting payment has renewal in it. - public function test_override_selected_currency_returns_order_currency_code_when_order_awaiting_payment_has_renewal() { - // Set up an order with a non-default currency. - $order = WC_Helper_Order::create_order(); - $order->set_currency( 'JPY' ); - $order->save(); - WC()->session->set( 'order_awaiting_payment', $order->get_id() ); + /** + * Will return the specified codes due to the cart check looks first in the cart object, and then in the session. With the first + * check, the cart object is empty, so the session is checked. With the second check, the cart object now has a subscription, so + * its code is returned. + * + * This confirms that the get_subscription_type_from_cart method is working correctly. + * + * @dataProvider provider_sub_types_renewal_resubscribe_switch + */ + public function test_override_selected_currency_return_currency_code_when_sub_type_in_cart( $sub_type ) { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( $sub_type ); - // Mock that order has the renewal in the cart. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_order_contains_renewal( true ); + // Arrange: Set the currency for the sub and update the cart items in the session. + $mock_subscription->set_currency( 'JPY' ); + WC()->session->set( 'cart', $cart_items ); + // Act/Assert: Confirm that the currency is what we set. $this->assertSame( 'JPY', $this->woocommerce_subscriptions->override_selected_currency( false ) ); - } - - // Returns false when order awaiting payment does not have a renewal in it. - public function test_override_selected_currency_return_false_when_order_awaiting_payment_has_no_renewal() { - // Set up an order with a non-default currency. - $order = WC_Helper_Order::create_order(); - $order->set_currency( 'JPY' ); - $order->save(); - WC()->session->set( 'order_awaiting_payment', $order->get_id() ); - // Mock that order renewal in the cart. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_order_contains_renewal( false ); + // Arrange: Change the sub's currency and update the cart contents in the WC object. + $mock_subscription->set_currency( 'EUR' ); + WC()->cart->set_cart_contents( $cart_items ); - $this->assertFalse( $this->woocommerce_subscriptions->override_selected_currency( false ) ); + // Act/Assert: Confirm the currency is what we set. + $this->assertSame( 'EUR', $this->woocommerce_subscriptions->override_selected_currency( false ) ); } // Test correct currency when shopper clicks upgrade/downgrade button in My Account – "switch". public function test_override_selected_currency_return_currency_code_for_switch_request() { - // Reset/clear any previous mocked state. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_wcs_get_order_type_cart_items( false ); - - // Set up an order with a non-default currency. - // Note we're using a WC_Order as a stand-in for a true WC_Subscription. - $mock_subscription = WC_Helper_Order::create_order(); + // Arrange: Create a mock subscription and assign its currency. + $mock_subscription = $this->create_mock_subscription(); $mock_subscription->set_currency( 'JPY' ); - $mock_subscription->save(); - - // Get the current user, then update the current user to the user for the order/sub. - $current_user_id = get_current_user_id(); - wp_set_current_user( $mock_subscription->get_customer_id() ); - // Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Blatantly hack mock request params for the test. + // Arrange: Blatantly hack mock request params for the test. $_GET['switch-subscription'] = $mock_subscription->get_id(); $_GET['_wcsnonce'] = wp_create_nonce( 'wcs_switch_request' ); + // Act/Assert: Confirm that the currency returned is that of the subscription. $this->assertSame( 'JPY', $this->woocommerce_subscriptions->override_selected_currency( false ) ); - - // Reset the current user. - wp_set_current_user( $current_user_id ); } // Return false if the current user doesn't match the user of the switching subscription. public function test_override_selected_currency_return_false_for_switch_request_when_no_user_match() { - // Reset/clear any previous mocked state. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_wcs_get_order_type_cart_items( false ); - - // Set up an order with a non-default currency. - // Note we're using a WC_Order as a stand-in for a true WC_Subscription. - $mock_subscription = WC_Helper_Order::create_order(); + // Arrange: Create a mock subscription and assign its currency and user. + $mock_subscription = $this->create_mock_subscription(); $mock_subscription->set_currency( 'JPY' ); - $mock_subscription->save(); - - // Get the current user, then update the current user to a random user. - $current_user_id = get_current_user_id(); - wp_set_current_user( 42 ); + $mock_subscription->set_customer_id( 42 ); - // Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Blatantly hack mock request params for the test. + // Arrange: Blatantly hack mock request params for the test. $_GET['switch-subscription'] = $mock_subscription->get_id(); $_GET['_wcsnonce'] = wp_create_nonce( 'wcs_switch_request' ); + // Act/Assert: Confirm that false is returned. $this->assertFalse( $this->woocommerce_subscriptions->override_selected_currency( false ) ); - - // Reset the current user. - wp_set_current_user( $current_user_id ); - } - - // Returns code due to cart contains a subscription switch. - public function test_override_selected_currency_return_currency_code_when_switch_in_cart() { - // Reset/clear any previous mocked state. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - - // Mock order with custom currency for switch cart item. - // Note we're using a WC_Order as a stand-in for a true WC_Subscription. - $mock_subscription = WC_Helper_Order::create_order(); - $mock_subscription->set_currency( 'JPY' ); - $mock_subscription->save(); - - // Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Mock cart to simulate a switch cart item referencing our subscription. - $this->mock_wcs_get_order_type_cart_items( $mock_subscription->get_id() ); - - $this->assertSame( 'JPY', $this->woocommerce_subscriptions->override_selected_currency( false ) ); } - // Returns code due to cart contains a subscription resubscribe. - public function test_override_selected_currency_return_currency_code_when_resubscribe_in_cart() { - // Reset/clear any previous mocked state. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_get_order_type_cart_items( false ); + // The default passed into should_convert_product_price is true, this passes false to confirm false is returned. + public function test_should_convert_product_price_return_false_when_false_passed() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items(); - // Mock order with custom currency for switch cart item. - // Note we're using a WC_Order as a stand-in for a true WC_Subscription. - $mock_subscription = WC_Helper_Order::create_order(); + // Arrange: Set the currency for the sub and update the cart items in the session. $mock_subscription->set_currency( 'JPY' ); - $mock_subscription->save(); + WC()->session->set( 'cart', $cart_items ); - // Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Mock cart to simulate a resubscribe cart item referencing our subscription. - $this->mock_wcs_cart_contains_resubscribe( $mock_subscription->get_id() ); - - $this->assertSame( 'JPY', $this->woocommerce_subscriptions->override_selected_currency( false ) ); - } - - public function test_should_convert_product_price_return_false_when_false_passed() { - // Conditions added to return true, but it should return false if passed. - $this->mock_utils->method( 'is_call_in_backtrace' )->willReturn( false ); - $this->mock_wcs_cart_contains_renewal( 42, 43 ); - $this->mock_wcs_cart_contains_resubscribe( 42 ); + // Arrange: Set expecation that is_call_in_backtrace should not be called. + $this->mock_utils + ->expects( $this->never() ) + ->method( 'is_call_in_backtrace' ); + // Act/Assert: Confirm that false is returned if passed. $this->assertFalse( $this->woocommerce_subscriptions->should_convert_product_price( false, $this->mock_product ) ); } - public function test_should_convert_product_price_return_false_when_renewal_in_cart() { - // Arrange: Create a subscription to be used. - $mock_subscription = new WC_Subscription(); - $mock_subscription->set_has_product( true ); - - // Arrange: Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); + /** + * Confirm that false is returned if specific types of subs are in the cart and there are specific calls in the backtrace. + * + * @dataProvider provider_sub_types_renewal_resubscribe + */ + public function test_should_convert_product_price_return_false_when_sub_type_in_cart_and_backtrace_match( $sub_type ) { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( $sub_type ); - // Arrange: Set our mock return values. - $this->mock_wcs_cart_contains_renewal( 42, 43, 44 ); - $this->mock_wcs_cart_contains_resubscribe( false ); + // Arrange: Set expectation and return for is_call_in_backtrace. $this->mock_utils + ->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) ->with( [ @@ -534,85 +452,104 @@ function ( $id ) use ( $mock_subscription ) { ) ->willReturn( true ); - // Act: Attempt to convert the subscription price. - $result = $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ); - - // Assert: Confirm the result value is false. - $this->assertFalse( $result ); + // Act/Assert: Confirm the result value is false. + $this->assertFalse( $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ) ); } - public function test_should_convert_product_price_return_false_when_resubscribe_in_cart() { - // Arrange: Create a subscription to be used. - $mock_subscription = new WC_Subscription(); - $mock_subscription->set_has_product( true ); - - // Arrange: Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); + /** + * Confirm that true is returned even if there are specific sub types in the cart, but the backtraces are not correct. + * + * @dataProvider provider_sub_types_renewal_resubscribe + */ + public function test_should_convert_product_price_return_true_when_sub_type_in_cart_and_backtraces_do_not_match( $sub_type ) { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( $sub_type ); - // Arrange: Set our mock return values. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( 42 ); + // Arrange: Set expectations and returns for is_call_in_backtrace. $this->mock_utils + ->expects( $this->exactly( 2 ) ) ->method( 'is_call_in_backtrace' ) - ->with( + ->withConsecutive( [ - 'WC_Cart_Totals->calculate_item_totals', - 'WC_Cart->get_product_subtotal', - 'wc_get_price_excluding_tax', - 'wc_get_price_including_tax', - ] + [ + 'WC_Cart_Totals->calculate_item_totals', + 'WC_Cart->get_product_subtotal', + 'wc_get_price_excluding_tax', + 'wc_get_price_including_tax', + ], + ], + [ [ 'WC_Payments_Subscription_Service->get_recurring_item_data_for_subscription' ] ] ) - ->willReturn( true ); - - // Act: Attempt to convert the subscription price. - $result = $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ); + ->willReturn( false ); - // Assert: Confirm the result value is false. - $this->assertFalse( $result ); + // Act/Assert: Confirm the result value is true. + $this->assertTrue( $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ) ); } - public function test_should_convert_product_price_return_true_when_backtrace_does_not_match() { - // Arrange: Create a subscription to be used. - $mock_subscription = new WC_Subscription(); - $mock_subscription->set_has_product( true ); - - // Arrange: Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Arrange: Set our mock return values. - $this->mock_wcs_cart_contains_renewal( 42, 43, 44 ); - $this->mock_wcs_cart_contains_resubscribe( 42 ); - $this->mock_utils->method( 'is_call_in_backtrace' )->willReturn( false ); + /** + * Confirm that true is returned even if there are specific sub types in the cart, but the backtraces are not correct. + * This is the same as the above, with the second backtrace check being true, so the third one is now checked. + * + * @dataProvider provider_sub_types_renewal_resubscribe + */ + public function test_should_convert_product_price_return_true_when_sub_type_in_cart_and_backtraces_do_not_match_exactly( $sub_type ) { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( $sub_type ); - // Act: Attempt to convert the subscription price. - $result = $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ); + // Arrange: Set expectations and returns for is_call_in_backtrace. + $this->mock_utils + ->expects( $this->exactly( 3 ) ) + ->method( 'is_call_in_backtrace' ) + ->withConsecutive( + [ + [ + 'WC_Cart_Totals->calculate_item_totals', + 'WC_Cart->get_product_subtotal', + 'wc_get_price_excluding_tax', + 'wc_get_price_including_tax', + ], + ], + [ [ 'WC_Payments_Subscription_Service->get_recurring_item_data_for_subscription' ] ], + [ [ 'WC_Product->get_price' ] ] + ) + ->willReturn( false, true, false ); - // Assert: Confirm the result value is true. - $this->assertTrue( $result ); + // Act/Assert: Confirm the result value is true. + $this->assertTrue( $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ) ); } - public function test_should_convert_product_price_return_true_with_no_subscription_actions_in_cart() { + // Confirm if there are no sub_types in cart and the first backtrace does not match, true is returned. + public function test_should_convert_product_price_return_true_with_no_sub_types_in_cart_and_no_backtrace_match() { + // Arrange: Set expectation and return for is_call_in_backtrace. $this->mock_utils ->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) ->with( [ 'WC_Payments_Subscription_Service->get_recurring_item_data_for_subscription' ] ) ->willReturn( false ); - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); + // Act/Assert: Confirm the result value is true. + $this->assertTrue( $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ) ); + } + + // Confirm if there are no sub_types in cart and the second backtrace does not match, true is returned. + public function test_should_convert_product_price_return_true_with_no_sub_types_in_cart_and_no_second_backtrace_match() { + // Arrange: Set expectations and returns for is_call_in_backtrace. + $this->mock_utils + ->expects( $this->exactly( 2 ) ) + ->method( 'is_call_in_backtrace' ) + ->withConsecutive( + [ [ 'WC_Payments_Subscription_Service->get_recurring_item_data_for_subscription' ] ], + [ [ 'WC_Product->get_price' ] ] + ) + ->willReturn( true, false ); + + // Act/Assert: Confirm the result value is true. $this->assertTrue( $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_product ) ); } // Test for when WCPay Subs is getting the product's price for the sub creation. public function test_should_convert_product_price_return_false_when_get_recurring_item_data_for_subscription() { + // Arrange: Set expectations and returns for is_call_in_backtrace. $this->mock_utils ->expects( $this->exactly( 2 ) ) ->method( 'is_call_in_backtrace' ) @@ -622,64 +559,93 @@ public function test_should_convert_product_price_return_false_when_get_recurrin ) ->willReturn( true, true ); - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); + // Act/Assert: Confirm the result value is false. $this->assertFalse( $this->woocommerce_subscriptions->should_convert_product_price( true, $this->mock_coupon ) ); } + /** + * This method should return false if false is passed. + * The test does not add a renewal to the cart, which would cause it to return true, but it shouldn't make it there. + * The is_call_in_backtrace call should also never be called. + */ public function test_should_convert_coupon_amount_return_false_if_false_passed() { - // Conditions added to return true, but should return false if false passed. + // Arrange: Set expectation for is_call_in_backtrace. $this->mock_utils - ->expects( $this->exactly( 0 ) ) + ->expects( $this->never() ) ->method( 'is_call_in_backtrace' ); - $this->mock_wcs_cart_contains_renewal( false ); + // Act/Assert: Confirm the result value is false. $this->assertFalse( $this->woocommerce_subscriptions->should_convert_coupon_amount( false, $this->mock_coupon ) ); } - public function test_should_convert_coupon_amount_return_false_when_renewal_in_cart() { - $this->mock_utils - ->expects( $this->exactly( 2 ) ) - ->method( 'is_call_in_backtrace' ) - ->withConsecutive( - [ [ 'WCS_Cart_Early_Renewal->setup_cart' ] ], - [ [ 'WC_Discounts->apply_coupon' ] ] - ) - ->willReturn( false, true ); - + // Confirm that if there's a subscription percentage coupon type, we don't want to convert its amount. + public function test_should_convert_coupon_amount_return_false_when_subscription_percent_coupon_type() { + // Arrange: Set expectation and return for our mock coupon. $this->mock_coupon + ->expects( $this->once() ) ->method( 'get_discount_type' ) - ->willReturn( 'recurring_fee' ); + ->willReturn( 'recurring_percent' ); - $this->mock_wcs_cart_contains_renewal( 42, 43 ); + // Arrange: Set expectations and returns for is_call_in_backtrace. + $this->mock_utils + ->expects( $this->never() ) + ->method( 'is_call_in_backtrace' ); + + // Act/Assert: Confirm the result value is false. $this->assertFalse( $this->woocommerce_subscriptions->should_convert_coupon_amount( true, $this->mock_coupon ) ); } + // Confirm true is returned if there is not a renewal in the cart. public function test_should_convert_coupon_amount_return_true_with_no_renewal_in_cart() { + // Arrange: Set expectation and return for our mock coupon. + $this->mock_coupon + ->expects( $this->once() ) + ->method( 'get_discount_type' ) + ->willReturn( 'recurring_fee' ); + + // Arrange: Set expectations and returns for is_call_in_backtrace. $this->mock_utils - ->expects( $this->exactly( 0 ) ) + ->expects( $this->never() ) ->method( 'is_call_in_backtrace' ); - $this->mock_wcs_cart_contains_renewal( false ); + // Act/Assert: Confirm the result value is true. $this->assertTrue( $this->woocommerce_subscriptions->should_convert_coupon_amount( true, $this->mock_coupon ) ); } + // Confirm true is returned if there's a renewal in the cart, but it's not an early renewal. public function test_should_convert_coupon_amount_return_true_with_early_renewal_in_backtrace() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'renewal' ); + + // Arrange: Set expectation and return for our mock coupon. + $this->mock_coupon + ->expects( $this->once() ) + ->method( 'get_discount_type' ) + ->willReturn( 'recurring_fee' ); + + // Arrange: Set expectation and return for is_call_in_backtrace. This exits our last test and allows the true return. $this->mock_utils ->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) ->with( [ 'WCS_Cart_Early_Renewal->setup_cart' ] ) ->willReturn( true ); - $this->mock_coupon - ->method( 'get_discount_type' ) - ->willReturn( 'recurring_fee' ); - - $this->mock_wcs_cart_contains_renewal( 42, 43 ); + // Act/Assert: Confirm the result value is true. $this->assertTrue( $this->woocommerce_subscriptions->should_convert_coupon_amount( true, $this->mock_coupon ) ); } + // Confirm true is returned if there's a renewal in the cart, if it is an early renewal, but the apply_coupon call is not found in the backtrace. public function test_should_convert_coupon_amount_return_true_when_apply_coupon_not_in_backtrace() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'renewal' ); + + // Arrange: Set expectation and return for our mock coupon. + $this->mock_coupon + ->expects( $this->once() ) + ->method( 'get_discount_type' ) + ->willReturn( 'recurring_fee' ); + + // Arrange: Set expectation and return for is_call_in_backtrace. This exits our last test and allows the true return. $this->mock_utils ->expects( $this->exactly( 2 ) ) ->method( 'is_call_in_backtrace' ) @@ -689,15 +655,22 @@ public function test_should_convert_coupon_amount_return_true_when_apply_coupon_ ) ->willReturn( false, false ); - $this->mock_coupon - ->method( 'get_discount_type' ) - ->willReturn( 'recurring_fee' ); - - $this->mock_wcs_cart_contains_renewal( 42, 43 ); + // Act/Assert: Confirm the result value is true. $this->assertTrue( $this->woocommerce_subscriptions->should_convert_coupon_amount( true, $this->mock_coupon ) ); } + // Confirm true is returned if there's a renewal in the cart, if it is an early renewal, the coupon is being applied, but it's the wrong coupon type. public function test_should_convert_coupon_amount_return_true_when_coupon_type_does_not_match() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'renewal' ); + + // Arrange: Set expectation and return for our mock coupon. The second call exits our last test and allows the true return. + $this->mock_coupon + ->expects( $this->exactly( 2 ) ) + ->method( 'get_discount_type' ) + ->willReturn( 'failing_fee' ); + + // Arrange: Set expectation and return for is_call_in_backtrace. $this->mock_utils ->expects( $this->exactly( 2 ) ) ->method( 'is_call_in_backtrace' ) @@ -707,190 +680,143 @@ public function test_should_convert_coupon_amount_return_true_when_coupon_type_d ) ->willReturn( false, true ); - $this->mock_coupon - ->method( 'get_discount_type' ) - ->willReturn( 'failing_fee' ); - - $this->mock_wcs_cart_contains_renewal( 42, 43 ); + // Act/Assert: Confirm the result value is true. $this->assertTrue( $this->woocommerce_subscriptions->should_convert_coupon_amount( true, $this->mock_coupon ) ); } - public function test_should_convert_coupon_amount_return_false_when_percentage_coupon_used() { + // Confirm false is returned if there's a renewal in the cart, the backtraces match, and the coupon is the proper type. + public function test_should_convert_coupon_amount_return_false_when_renewal_in_cart() { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( 'renewal' ); + + // Arrange: Set expectations and returns for is_call_in_backtrace. $this->mock_utils - ->expects( $this->exactly( 0 ) ) - ->method( 'is_call_in_backtrace' ); + ->expects( $this->exactly( 2 ) ) + ->method( 'is_call_in_backtrace' ) + ->withConsecutive( + [ [ 'WCS_Cart_Early_Renewal->setup_cart' ] ], + [ [ 'WC_Discounts->apply_coupon' ] ] + ) + ->willReturn( false, true ); + // Arrange: Set expectation and return for our mock coupon. $this->mock_coupon ->method( 'get_discount_type' ) - ->willReturn( 'recurring_percent' ); + ->willReturn( 'recurring_fee' ); - $this->mock_wcs_cart_contains_renewal( false ); + // Act/Assert: Confirm the result value is false. $this->assertFalse( $this->woocommerce_subscriptions->should_convert_coupon_amount( true, $this->mock_coupon ) ); } - public function test_should_hide_widgets_return_true_if_true_passed() { - // Conditions set to return false, but should return true if true passed. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_wcs_get_order_type_cart_items( false ); - - $this->assertTrue( $this->woocommerce_subscriptions->should_hide_widgets( true ) ); + // If true is passed to the method, true should be returned immediately. + public function test_should_disable_currency_switching_return_true_if_true_passed() { + // Act/Assert: Confirm the result value is true. + $this->assertTrue( $this->woocommerce_subscriptions->should_disable_currency_switching( true ) ); } - // Should return false since all checks return false. - public function test_should_hide_widgets_return_false() { - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->mock_wcs_get_order_type_cart_items( false ); - $this->assertFalse( $this->woocommerce_subscriptions->should_hide_widgets( false ) ); + // If false is passed to the method and none of the checks are true, false is returned. + public function test_should_disable_currency_switching_return_false() { + // Act/Assert: Confirm the result value is false. + $this->assertFalse( $this->woocommerce_subscriptions->should_disable_currency_switching( false ) ); } - public function test_should_hide_widgets_return_true_when_renewal_in_cart() { - $this->mock_wcs_cart_contains_renewal( 42, 43 ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->assertTrue( $this->woocommerce_subscriptions->should_hide_widgets( false ) ); - } + /** + * Confirm true is returned when sub types are in cart. + * + * @dataProvider provider_sub_types_renewal_resubscribe_switch + */ + public function test_should_disable_currency_switching_return_true_when_sub_type_in_cart( $sub_type ) { + // Arrange: Create a subscription and cart_items to be used. + [ $mock_subscription, $cart_items ] = $this->get_mock_subscription_and_session_cart_items( $sub_type ); - public function test_should_hide_widgets_return_true_when_resubscribe_in_cart() { - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_cart_contains_resubscribe( 42 ); - $this->assertTrue( $this->woocommerce_subscriptions->should_hide_widgets( false ) ); + // Act/Assert: Confirm the result value is true. + $this->assertTrue( $this->woocommerce_subscriptions->should_disable_currency_switching( false ) ); } - // Should return true if switch found in GET, like on product page. - public function test_should_hide_widgets_return_true_when_starting_subscrition_switch() { - // Reset/clear any previous mocked state. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_get_order_type_cart_items( false ); + // Should return true if switch found in GET, for when a customer is doing a subscription switch. + public function test_should_disable_currency_switching_return_true_when_starting_subscription_switch() { + // Arrange: Create a mock subscription to use. + $mock_subscription = $this->create_mock_subscription(); - // Set up an order to use for the test. - // Note we're using a WC_Order as a stand-in for a true WC_Subscription. - $mock_subscription = WC_Helper_Order::create_order(); - $mock_subscription->save(); - - // Get the current user, then update the current user to the user for the order/sub. - $current_user_id = get_current_user_id(); - wp_set_current_user( $mock_subscription->get_customer_id() ); - - // Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Blatantly hack mock request params for the test. + // Arrange: Blatantly hack mock request params for the test. $_GET['switch-subscription'] = $mock_subscription->get_id(); $_GET['_wcsnonce'] = wp_create_nonce( 'wcs_switch_request' ); - $this->assertTrue( $this->woocommerce_subscriptions->should_hide_widgets( false ) ); - - // Reset the current user. - wp_set_current_user( $current_user_id ); + // Act/Assert: Confirm that true is returned. + $this->assertTrue( $this->woocommerce_subscriptions->should_disable_currency_switching( false ) ); } // Should return false since users will not match. - public function test_should_hide_widgets_return_false_when_starting_subscrition_switch_and_no_user_match() { - // Reset/clear any previous mocked state. - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_get_order_type_cart_items( false ); - - // Set up an order to use for the test. - // Note we're using a WC_Order as a stand-in for a true WC_Subscription. - $mock_subscription = WC_Helper_Order::create_order(); - $mock_subscription->save(); + public function test_should_disable_currency_switching_return_false_when_starting_subscrition_switch_and_no_user_match() { + // Arrange: Create a mock subscription and assign its user. + $mock_subscription = $this->create_mock_subscription(); + $mock_subscription->set_customer_id( 42 ); - // Get the current user, then update the current user to a random ID. - $current_user_id = get_current_user_id(); - wp_set_current_user( 42 ); - - // Mock wcs_get_subscription to return our mock subscription. - WC_Subscriptions::set_wcs_get_subscription( - function ( $id ) use ( $mock_subscription ) { - return $mock_subscription; - } - ); - - // Blatantly hack mock request params for the test. + // Arrange: Blatantly hack mock request params for the test. $_GET['switch-subscription'] = $mock_subscription->get_id(); $_GET['_wcsnonce'] = wp_create_nonce( 'wcs_switch_request' ); - $this->assertFalse( $this->woocommerce_subscriptions->should_hide_widgets( false ) ); + // Act/Assert: Confirm that false is returned. + $this->assertFalse( $this->woocommerce_subscriptions->should_disable_currency_switching( false ) ); + } - // Reset the current user. - wp_set_current_user( $current_user_id ); + public function provider_sub_types_renewal_resubscribe_switch() { + return [ + 'renewal' => [ 'renewal' ], + 'resubscribe' => [ 'resubscribe' ], + 'switch' => [ 'switch' ], + ]; } - // Should return true if switch found in cart. - public function test_should_hide_widgets_return_true_when_switch_found_in_cart() { - $this->mock_wcs_cart_contains_renewal( false ); - $this->mock_wcs_get_order_type_cart_items( true ); - $this->mock_wcs_cart_contains_resubscribe( false ); - $this->assertTrue( $this->woocommerce_subscriptions->should_hide_widgets( false ) ); + public function provider_sub_types_renewal_resubscribe() { + return [ + 'renewal' => [ 'renewal' ], + 'resubscribe' => [ 'resubscribe' ], + ]; } - // Simulate (mock) a renewal in the cart. - // Pass 0 / no args to unmock. - private function mock_wcs_cart_contains_renewal( $product_id = 0, $renewal_order_id = 0, $subscription_id = 0 ) { - WC_Subscriptions::wcs_cart_contains_renewal( - function () use ( $product_id, $renewal_order_id, $subscription_id ) { - if ( $product_id && $renewal_order_id ) { - return [ - 'product_id' => $product_id, - 'subscription_renewal' => [ - 'renewal_order_id' => $renewal_order_id, - 'subscription_id' => $subscription_id, - ], - ]; - } + /** + * Creates a mock subscription for us to be able to use in our tests. + * It also sets up the wcs_get_subscription mock method to return that sub. + */ + private function create_mock_subscription() { + // Create the mock subscription. + $mock_subscription = new WC_Subscription( 404 ); - return false; + // Mock wcs_get_subscription to return our mock subscription. + WC_Subscriptions::set_wcs_get_subscription( + function ( $id ) use ( $mock_subscription ) { + return $mock_subscription; } ); - } - private function mock_wcs_get_order_type_cart_items( $switch_id = 0 ) { - WC_Subscriptions::wcs_get_order_type_cart_items( - function () use ( $switch_id ) { - if ( $switch_id ) { - return [ - [ - 'product_id' => 42, - 'key' => 'abc123', - 'subscription_switch' => [ - 'subscription_id' => $switch_id, - ], - ], - ]; - } - - return []; - } - ); + return $mock_subscription; } - private function mock_wcs_cart_contains_resubscribe( $subscription_id = 0 ) { - WC_Subscriptions::wcs_cart_contains_resubscribe( - function () use ( $subscription_id ) { - if ( $subscription_id ) { - return [ - 'product_id' => 42, - 'subscription_resubscribe' => [ - 'subscription_id' => $subscription_id, - ], - ]; - } + /** + * Creates a mock subsciption, and then adds it to the session's cart array. + */ + private function get_mock_subscription_and_session_cart_items( $sub_type = 'renewal' ) { + // Create the mock subscription. + $mock_subscription = $this->create_mock_subscription(); + + // Create our cart items. + $cart_items = [ + [ + 'subscription_' . $sub_type => [ + 'subscription_id' => $mock_subscription->get_id(), + ], + 'product_id' => $this->mock_product->get_id(), + 'key' => 'abc123', + ], + ]; - return false; - } - ); - } + // Set the cart items in the session. + WC()->session->set( 'cart', $cart_items ); - private function mock_wcs_order_contains_renewal( $renewal = false ) { - WC_Subscriptions::wcs_order_contains_renewal( - function () use ( $renewal ) { - return $renewal; - } - ); + return [ + $mock_subscription, + $cart_items, + ]; } } diff --git a/tests/unit/multi-currency/test-class-analytics.php b/tests/unit/multi-currency/test-class-analytics.php index f2e672fac69..0ca89b7fd6e 100644 --- a/tests/unit/multi-currency/test-class-analytics.php +++ b/tests/unit/multi-currency/test-class-analytics.php @@ -131,6 +131,19 @@ public function test_register_customer_currencies() { $this->assertTrue( $data_registry->exists( 'customerCurrencies' ) ); } + public function test_has_multi_currency_orders() { + + // Use reflection to make the private method has_multi_currency_orders accessible. + $method = new ReflectionMethod( Analytics::class, 'has_multi_currency_orders' ); + $method->setAccessible( true ); + + // Now, you can call the has_multi_currency_orders method using the ReflectionMethod object. + $result = $method->invoke( $this->analytics ); + + $this->assertTrue( $result ); + + } + public function test_register_customer_currencies_for_empty_customer_currencies() { $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_all_customer_currencies' ) diff --git a/tests/unit/multi-currency/test-class-compatibility.php b/tests/unit/multi-currency/test-class-compatibility.php index 607fcf158d9..edea21668e9 100644 --- a/tests/unit/multi-currency/test-class-compatibility.php +++ b/tests/unit/multi-currency/test-class-compatibility.php @@ -201,4 +201,31 @@ public function test_filter_woocommerce_order_query_with_object_not_array() { $this->assertEquals( $expected, $this->compatibility->convert_order_prices( $expected, [] ) ); } + + // The should_disable_currency_switching should return false by default. + public function test_should_disable_currency_switching_return_false_by_default() { + // Act/Assert: Confirm false is returned by default. + $this->assertFalse( $this->compatibility->should_disable_currency_switching() ); + } + + // If on the pay_for_order page, then should_disable_currency_switching should return true. + public function test_should_disable_currency_switching_return_true_on_pay_for_order() { + // Arrange: Blatantly hack mock request params for the test. + $_GET['pay_for_order'] = true; + + // Act/Assert: Confirm true is returned if on the pay_for_order page. + $this->assertTrue( $this->compatibility->should_disable_currency_switching() ); + } + + // If filtered to true, then should_disable_currency_switching should return true. + public function test_should_disable_currency_switching_return_true_on_filtered_true() { + // Arrange: Add filter to return true. + add_filter( MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching', '__return_true' ); + + // Act/Assert: Confirm true is returned if filtered to true. + $this->assertTrue( $this->compatibility->should_disable_currency_switching() ); + + // Arrange: Remove our filter. + remove_all_filters( MultiCurrency::FILTER_PREFIX . 'should_disable_currency_switching' ); + } } diff --git a/tests/unit/multi-currency/test-class-currency-switcher-block.php b/tests/unit/multi-currency/test-class-currency-switcher-block.php index cb3163649ba..843662ab39a 100644 --- a/tests/unit/multi-currency/test-class-currency-switcher-block.php +++ b/tests/unit/multi-currency/test-class-currency-switcher-block.php @@ -62,7 +62,7 @@ public function test_render_block_widget( $attributes, $test_styles ) { $symbol = $attributes['symbol'] ?? true; $this->mock_compatibility->expects( $this->once() ) - ->method( 'should_hide_widgets' ) + ->method( 'should_disable_currency_switching' ) ->willReturn( false ); $this->mock_multi_currency->expects( $this->once() ) @@ -184,7 +184,7 @@ public function test_widget_renders_hidden_input() { ]; $this->mock_compatibility->expects( $this->once() ) - ->method( 'should_hide_widgets' ) + ->method( 'should_disable_currency_switching' ) ->willReturn( false ); $this->mock_multi_currency->expects( $this->once() ) @@ -196,4 +196,79 @@ public function test_widget_renders_hidden_input() { $this->assertStringContainsString( '<input type="hidden" name="test_array[0][0]" value="test_array_value" />', $result ); $this->assertStringContainsString( '<input type="hidden" name="named_key[key]" value="value" />', $result ); } + + public function test_render_currency_option_will_escape_output() { + $currency_code = '"><script>alert("test")</script>'; + + // Arrange: Set the expected call and return values for should_disable_currency_switching and get_enabled_currencies. + $this->mock_compatibility + ->expects( $this->once() ) + ->method( 'should_disable_currency_switching' ) + ->willReturn( false ); + + $this->mock_multi_currency->expects( $this->once() ) + ->method( 'get_enabled_currencies' ) + ->willReturn( + [ + new Currency( 'USD' ), + new Currency( $currency_code, 1 ), + ] + ); + + $output = $this->currency_switcher_block->render_block_widget( [] ); + + // Ensure output is properly escaped. + $this->assertStringContainsString( esc_attr( $currency_code ), $output ); + $this->assertStringContainsString( esc_html( $currency_code ), $output ); + $this->assertStringNotContainsString( '<script>', $output ); + } + + public function test_render_block_widget_will_escape_output() { + $font_line_height = '1.2"><script>alert("test")</script>'; + $border_radius = '3"><script>alert("test")</script>'; + $block_attributes = [ + 'fontLineHeight' => $font_line_height, + 'borderRadius' => $border_radius, + ]; + + $output = $this->currency_switcher_block->render_block_widget( $block_attributes ); + + // Ensure output is properly escaped. + $this->assertStringContainsString( esc_attr( $font_line_height ), $output ); + $this->assertStringContainsString( esc_attr( $border_radius ), $output ); + $this->assertStringNotContainsString( '<script>', $output ); + } + + /** + * The widget should not be displayed if should_disable_currency_switching returns true. + */ + public function test_widget_does_not_render_on_hide() { + // Arrange: Set the expected call and return value for should_disable_currency_switching. + $this->mock_compatibility + ->expects( $this->once() ) + ->method( 'should_disable_currency_switching' ) + ->willReturn( true ); + + // Act/Assert: Confirm that when calling the renger method nothing is returned. + $this->assertSame( '', $this->currency_switcher_block->render_block_widget( [], '' ) ); + } + + /** + * The widget should not be displayed if there's only a single currency enabled. + */ + public function test_widget_does_not_render_on_single_currency() { + // Arrange: Set the expected call and return values for should_disable_currency_switching and get_enabled_currencies. + $this->mock_compatibility + ->expects( $this->once() ) + ->method( 'should_disable_currency_switching' ) + ->willReturn( false ); + + $this->mock_multi_currency + ->expects( $this->once() ) + ->method( 'get_enabled_currencies' ) + ->willReturn( [ new Currency( 'USD' ) ] ); + + // Act/Assert: Confirm that when calling the renger method nothing is returned. + $this->assertSame( '', $this->currency_switcher_block->render_block_widget( [], '' ) ); + } } diff --git a/tests/unit/multi-currency/test-class-currency-switcher-widget.php b/tests/unit/multi-currency/test-class-currency-switcher-widget.php index 62afd3cce34..7f6f88e299c 100644 --- a/tests/unit/multi-currency/test-class-currency-switcher-widget.php +++ b/tests/unit/multi-currency/test-class-currency-switcher-widget.php @@ -55,7 +55,7 @@ public function set_up() { } public function test_widget_renders_title_with_args() { - $this->mock_compatibility->method( 'should_hide_widgets' )->willReturn( false ); + $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); $instance = [ 'title' => 'Test Title', ]; @@ -64,13 +64,13 @@ public function test_widget_renders_title_with_args() { } public function test_widget_renders_enabled_currencies_with_symbol() { - $this->mock_compatibility->method( 'should_hide_widgets' )->willReturn( false ); + $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); $this->expectOutputRegex( '/value="USD">$ USD.+value="CAD">$ CAD.+value="EUR">€ EUR.+value="CHF">CHF/s' ); $this->render_widget(); } public function test_widget_renders_enabled_currencies_without_symbol() { - $this->mock_compatibility->method( 'should_hide_widgets' )->willReturn( false ); + $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); $instance = [ 'symbol' => 0, ]; @@ -79,7 +79,7 @@ public function test_widget_renders_enabled_currencies_without_symbol() { } public function test_widget_renders_enabled_currencies_with_symbol_and_flag() { - $this->mock_compatibility->method( 'should_hide_widgets' )->willReturn( false ); + $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); $instance = [ 'symbol' => 1, 'flag' => 1, @@ -99,20 +99,20 @@ public function test_widget_renders_hidden_input() { } public function test_widget_selects_selected_currency() { - $this->mock_compatibility->method( 'should_hide_widgets' )->willReturn( false ); + $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); $this->mock_multi_currency->method( 'get_selected_currency' )->willReturn( new Currency( 'CAD' ) ); $this->expectOutputRegex( '/<option value="CAD" selected>$ CAD/' ); $this->render_widget(); } public function test_widget_submits_form_on_change() { - $this->mock_compatibility->method( 'should_hide_widgets' )->willReturn( false ); + $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); $this->expectOutputRegex( '/onchange="this.form.submit\(\)"/' ); $this->render_widget(); } public function test_widget_does_not_render_on_hide() { - $this->mock_compatibility->method( 'should_hide_widgets' )->willReturn( true ); + $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( true ); $this->expectOutputString( '' ); $this->render_widget(); } @@ -121,7 +121,7 @@ public function test_widget_does_not_render_on_single_currency() { $mock_compatibility = $this->createMock( WCPay\MultiCurrency\Compatibility::class ); $mock_multi_currency = $this->createMock( WCPay\MultiCurrency\MultiCurrency::class ); - $mock_compatibility->method( 'should_hide_widgets' )->willReturn( false ); + $mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); $mock_multi_currency ->method( 'get_enabled_currencies' ) ->willReturn( diff --git a/tests/unit/multi-currency/test-class-multi-currency.php b/tests/unit/multi-currency/test-class-multi-currency.php index a4ea52bbd2b..8f08af4d7c6 100644 --- a/tests/unit/multi-currency/test-class-multi-currency.php +++ b/tests/unit/multi-currency/test-class-multi-currency.php @@ -500,6 +500,43 @@ function() { $this->assertNotFalse( has_filter( 'wp_footer', [ $this->multi_currency, 'display_geolocation_currency_update_notice' ] ) ); } + /** + * If compatibility->should_disable_currency_switching returns true, then we should not automatically change the customer currency + * or add the action that displays the notice that the currency was changed. + */ + public function test_update_selected_currency_by_geolocation_does_not_update_if_should_disable_currency_switching() { + // Arrange: Update the option to enable to auto currency switching. + update_option( 'wcpay_multi_currency_enable_auto_currency', 'yes' ); + + // Arrange: Add a filter to return a non US country. + add_filter( + 'woocommerce_geolocate_ip', + function() { + return 'CA'; + } + ); + + // Arrange: Set the expected calls and retruns for our mock classes. + $this->mock_localization_service + ->method( 'get_country_locale_data' ) + ->with( 'CA' ) + ->willReturn( [ 'currency_code' => 'CAD' ] ); + + $this->mock_utils + ->expects( $this->never() ) + ->method( 'set_customer_session_cookie' ); + + // Arrange: Blatantly hack mock request params for the test. + $_GET['pay_for_order'] = true; + + // Act: Call the tested method. + $this->multi_currency->update_selected_currency_by_geolocation(); + + // Assert: Confirm the session does not have a currency key set, and that the update notice action was not added. + $this->assertNull( WC()->session->get( WCPay\MultiCurrency\MultiCurrency::CURRENCY_SESSION_KEY ) ); + $this->assertFalse( has_filter( 'wp_footer', [ $this->multi_currency, 'display_geolocation_currency_update_notice' ] ) ); + } + public function test_display_geolocation_currency_update_notice() { WC()->session->set( WCPay\MultiCurrency\MultiCurrency::CURRENCY_SESSION_KEY, 'CAD' ); add_filter( @@ -978,7 +1015,7 @@ function( $key, $generator, $validator ) { $result = $this->multi_currency->get_all_customer_currencies(); - $this->assertEquals( [ 'GBP', 'EUR', 'USD' ], $result ); + $this->assertEquals( [ 'EUR', 'GBP', 'USD' ], $result ); foreach ( $mock_orders as $order_id ) { wp_delete_post( $order_id, true ); diff --git a/tests/unit/notes/test-class-wc-payments-notes-additional-payment-methods.php b/tests/unit/notes/test-class-wc-payments-notes-additional-payment-methods.php index 32a019b048c..0c9dae3b18a 100644 --- a/tests/unit/notes/test-class-wc-payments-notes-additional-payment-methods.php +++ b/tests/unit/notes/test-class-wc-payments-notes-additional-payment-methods.php @@ -28,7 +28,7 @@ public function test_get_note() { $note = WC_Payments_Notes_Additional_Payment_Methods::get_note(); $this->assertSame( 'Boost your sales by accepting new payment methods', $note->get_title() ); - $this->assertSame( 'Get early access to additional payment methods and an improved checkout experience, coming soon to WooPayments. <a href="https://woocommerce.com/document/woocommerce-payments/payment-methods/additional-payment-methods/" target="wcpay_upe_learn_more">Learn more</a>', $note->get_content() ); + $this->assertSame( 'Get early access to additional payment methods and an improved checkout experience, coming soon to WooPayments. <a href="https://woocommerce.com/document/woopayments/payment-methods/additional-payment-methods/" target="wcpay_upe_learn_more">Learn more</a>', $note->get_content() ); $this->assertSame( 'info', $note->get_type() ); $this->assertSame( 'wc-payments-notes-additional-payment-methods', $note->get_name() ); $this->assertSame( 'woocommerce-payments', $note->get_source() ); @@ -59,12 +59,11 @@ public function test_get_note_does_not_return_note_when_account_is_not_connected } public function test_get_note_returns_note_when_account_is_connected() { - $account_mock = $this->getMockBuilder( \WC_Payments_Account::class )->disableOriginalConstructor()->setMethods( [ 'is_stripe_connected' ] )->getMock(); - $account_mock->expects( $this->atLeastOnce() )->method( 'is_stripe_connected' )->will( - $this->returnValue( - true - ) - ); + $account_mock = $this->getMockBuilder( \WC_Payments_Account::class )->disableOriginalConstructor()->setMethods( [ 'is_stripe_connected', 'is_account_partially_onboarded', 'is_progressive_onboarding_in_progress' ] )->getMock(); + $account_mock->expects( $this->once() )->method( 'is_stripe_connected' )->willReturn( true ); + $account_mock->expects( $this->once() )->method( 'is_account_partially_onboarded' )->willReturn( false ); + $account_mock->expects( $this->once() )->method( 'is_progressive_onboarding_in_progress' )->willReturn( false ); + WC_Payments_Notes_Additional_Payment_Methods::set_account( $account_mock ); $note = WC_Payments_Notes_Additional_Payment_Methods::get_note(); @@ -72,6 +71,31 @@ public function test_get_note_returns_note_when_account_is_connected() { $this->assertSame( 'Boost your sales by accepting new payment methods', $note->get_title() ); } + public function test_get_note_returns_note_when_account_is_partially_onboarded() { + $account_mock = $this->getMockBuilder( \WC_Payments_Account::class )->disableOriginalConstructor()->setMethods( [ 'is_stripe_connected', 'is_account_partially_onboarded', 'is_progressive_onboarding_in_progress' ] )->getMock(); + $account_mock->expects( $this->once() )->method( 'is_stripe_connected' )->willReturn( true ); + $account_mock->expects( $this->once() )->method( 'is_account_partially_onboarded' )->willReturn( true ); + + WC_Payments_Notes_Additional_Payment_Methods::set_account( $account_mock ); + + $note = WC_Payments_Notes_Additional_Payment_Methods::get_note(); + + $this->assertNull( $note ); + } + + public function test_get_note_returns_note_when_account_is_progressive_in_progress() { + $account_mock = $this->getMockBuilder( \WC_Payments_Account::class )->disableOriginalConstructor()->setMethods( [ 'is_stripe_connected', 'is_account_partially_onboarded', 'is_progressive_onboarding_in_progress' ] )->getMock(); + $account_mock->expects( $this->once() )->method( 'is_stripe_connected' )->willReturn( true ); + $account_mock->expects( $this->once() )->method( 'is_account_partially_onboarded' )->willReturn( false ); + $account_mock->expects( $this->once() )->method( 'is_progressive_onboarding_in_progress' )->willReturn( true ); + + WC_Payments_Notes_Additional_Payment_Methods::set_account( $account_mock ); + + $note = WC_Payments_Notes_Additional_Payment_Methods::get_note(); + + $this->assertNull( $note ); + } + public function test_maybe_enable_feature_flag_redirects_to_onboarding_when_account_not_connected() { $account_mock = $this->getMockBuilder( \WC_Payments_Account::class )->disableOriginalConstructor()->setMethods( [ 'is_stripe_connected', 'redirect_to_onboarding_welcome_page' ] )->getMock(); $account_mock->expects( $this->atLeastOnce() )->method( 'is_stripe_connected' )->will( $this->returnValue( false ) ); diff --git a/tests/unit/notes/test-class-wc-payments-notes-set-up-stripelink.php b/tests/unit/notes/test-class-wc-payments-notes-set-up-stripelink.php index 3312bc6ebb3..23800550a95 100644 --- a/tests/unit/notes/test-class-wc-payments-notes-set-up-stripelink.php +++ b/tests/unit/notes/test-class-wc-payments-notes-set-up-stripelink.php @@ -52,7 +52,7 @@ public function test_stripelink_setup_get_note() { list( $set_up_action ) = $note->get_actions(); $this->assertSame( 'wc-payments-notes-set-up-stripe-link', $set_up_action->name ); $this->assertSame( 'Set up now', $set_up_action->label ); - $this->assertStringStartsWith( 'https://woocommerce.com/document/woocommerce-payments/payment-methods/link-by-stripe/', $set_up_action->query ); + $this->assertStringStartsWith( 'https://woocommerce.com/document/woopayments/payment-methods/link-by-stripe/', $set_up_action->query ); } public function test_stripelink_setup_note_null_when_upe_disabled() { diff --git a/tests/unit/payment-methods/test-class-upe-payment-gateway.php b/tests/unit/payment-methods/test-class-upe-payment-gateway.php index b01a28df986..e52cac12c1d 100644 --- a/tests/unit/payment-methods/test-class-upe-payment-gateway.php +++ b/tests/unit/payment-methods/test-class-upe-payment-gateway.php @@ -374,13 +374,17 @@ public function test_update_payment_intent_adds_customer_save_payment_and_level3 ->method( 'set_metadata' ) ->with( [ - 'customer_name' => 'Jeroen Sormani', - 'customer_email' => 'admin@example.org', - 'site_url' => 'http://example.org', - 'order_id' => $order_id, - 'order_number' => $order_number, - 'order_key' => $order->get_order_key(), - 'payment_type' => Payment_Type::SINGLE(), + 'customer_name' => 'Jeroen Sormani', + 'customer_email' => 'admin@example.org', + 'site_url' => 'http://example.org', + 'order_id' => $order_id, + 'order_number' => $order_number, + 'order_key' => $order->get_order_key(), + 'payment_type' => Payment_Type::SINGLE(), + 'gateway_type' => 'upe', + 'checkout_type' => '', + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', ] ); @@ -472,13 +476,17 @@ public function test_update_payment_intent_with_selected_upe_payment_method() { ->method( 'set_metadata' ) ->with( [ - 'customer_name' => 'Jeroen Sormani', - 'customer_email' => 'admin@example.org', - 'site_url' => 'http://example.org', - 'order_id' => $order_id, - 'order_number' => $order_number, - 'order_key' => $order->get_order_key(), - 'payment_type' => Payment_Type::SINGLE(), + 'customer_name' => 'Jeroen Sormani', + 'customer_email' => 'admin@example.org', + 'site_url' => 'http://example.org', + 'order_id' => $order_id, + 'order_number' => $order_number, + 'order_key' => $order->get_order_key(), + 'payment_type' => Payment_Type::SINGLE(), + 'gateway_type' => 'upe', + 'checkout_type' => '', + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', ] ); @@ -566,13 +574,17 @@ public function test_update_payment_intent_with_payment_country() { ->method( 'set_metadata' ) ->with( [ - 'customer_name' => 'Jeroen Sormani', - 'customer_email' => 'admin@example.org', - 'site_url' => 'http://example.org', - 'order_id' => $order_id, - 'order_number' => $order_number, - 'order_key' => $order->get_order_key(), - 'payment_type' => Payment_Type::SINGLE(), + 'customer_name' => 'Jeroen Sormani', + 'customer_email' => 'admin@example.org', + 'site_url' => 'http://example.org', + 'order_id' => $order_id, + 'order_number' => $order_number, + 'order_key' => $order->get_order_key(), + 'payment_type' => Payment_Type::SINGLE(), + 'gateway_type' => 'upe', + 'checkout_type' => '', + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', ] ); @@ -1543,7 +1555,7 @@ public function test_payment_methods_show_correct_default_outputs() { $this->assertEquals( 'SEPA Direct Debit', $sepa_method->get_title() ); $this->assertEquals( 'SEPA Direct Debit', $sepa_method->get_title( $mock_sepa_details ) ); $this->assertTrue( $sepa_method->is_enabled_at_checkout() ); - $this->assertTrue( $sepa_method->is_reusable() ); + $this->assertFalse( $sepa_method->is_reusable() ); $this->assertEquals( 'ideal', $ideal_method->get_id() ); $this->assertEquals( 'iDEAL', $ideal_method->get_title() ); @@ -1590,7 +1602,7 @@ public function test_only_reusabled_payment_methods_enabled_with_subscription_it $this->assertFalse( $sofort_method->is_enabled_at_checkout() ); $this->assertFalse( $bancontact_method->is_enabled_at_checkout() ); $this->assertFalse( $eps_method->is_enabled_at_checkout() ); - $this->assertTrue( $sepa_method->is_enabled_at_checkout() ); + $this->assertFalse( $sepa_method->is_enabled_at_checkout() ); $this->assertFalse( $p24_method->is_enabled_at_checkout() ); $this->assertFalse( $ideal_method->is_enabled_at_checkout() ); $this->assertFalse( $becs_method->is_enabled_at_checkout() ); diff --git a/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php b/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php index 4b829032e9e..b6a2aa24a93 100644 --- a/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php +++ b/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php @@ -408,13 +408,17 @@ public function test_update_payment_intent_adds_customer_save_payment_and_level3 ->method( 'create_customer_for_user' ); $metadata = [ - 'customer_name' => 'Jeroen Sormani', - 'customer_email' => 'admin@example.org', - 'site_url' => 'http://example.org', - 'order_id' => $order_id, - 'order_number' => $order_number, - 'order_key' => $order->get_order_key(), - 'payment_type' => Payment_Type::SINGLE(), + 'customer_name' => 'Jeroen Sormani', + 'customer_email' => 'admin@example.org', + 'site_url' => 'http://example.org', + 'order_id' => $order_id, + 'order_number' => $order_number, + 'order_key' => $order->get_order_key(), + 'payment_type' => Payment_Type::SINGLE(), + 'gateway_type' => 'split_upe', + 'checkout_type' => '', + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', ]; $level3 = [ @@ -479,13 +483,18 @@ public function test_update_payment_intent_with_selected_upe_payment_method() { ->method( 'create_customer_for_user' ); $metadata = [ - 'customer_name' => 'Jeroen Sormani', - 'customer_email' => 'admin@example.org', - 'site_url' => 'http://example.org', - 'order_id' => $order_id, - 'order_number' => $order_number, - 'order_key' => $order->get_order_key(), - 'payment_type' => Payment_Type::SINGLE(), + 'customer_name' => 'Jeroen Sormani', + 'customer_email' => 'admin@example.org', + 'site_url' => 'http://example.org', + 'order_id' => $order_id, + 'order_number' => $order_number, + 'order_key' => $order->get_order_key(), + 'payment_type' => Payment_Type::SINGLE(), + 'gateway_type' => 'split_upe', + 'checkout_type' => '', + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', + ]; $level3 = [ @@ -553,13 +562,17 @@ public function test_update_payment_intent_with_payment_country() { ->method( 'create_customer_for_user' ); $metadata = [ - 'customer_name' => 'Jeroen Sormani', - 'customer_email' => 'admin@example.org', - 'site_url' => 'http://example.org', - 'order_id' => $order_id, - 'order_number' => $order_number, - 'order_key' => $order->get_order_key(), - 'payment_type' => Payment_Type::SINGLE(), + 'customer_name' => 'Jeroen Sormani', + 'customer_email' => 'admin@example.org', + 'site_url' => 'http://example.org', + 'order_id' => $order_id, + 'order_number' => $order_number, + 'order_key' => $order->get_order_key(), + 'payment_type' => Payment_Type::SINGLE(), + 'gateway_type' => 'split_upe', + 'checkout_type' => '', + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', ]; $level3 = [ @@ -1546,7 +1559,7 @@ public function test_payment_methods_show_correct_default_outputs() { $this->assertEquals( 'SEPA Direct Debit', $sepa_method->get_title() ); $this->assertEquals( 'SEPA Direct Debit', $sepa_method->get_title( $mock_sepa_details ) ); $this->assertTrue( $sepa_method->is_enabled_at_checkout() ); - $this->assertTrue( $sepa_method->is_reusable() ); + $this->assertFalse( $sepa_method->is_reusable() ); $this->assertEquals( 'ideal', $ideal_method->get_id() ); $this->assertEquals( 'iDEAL', $ideal_method->get_title() ); @@ -1581,7 +1594,7 @@ public function test_only_reusabled_payment_methods_enabled_with_subscription_it $this->assertFalse( $sofort_method->is_enabled_at_checkout() ); $this->assertFalse( $bancontact_method->is_enabled_at_checkout() ); $this->assertFalse( $eps_method->is_enabled_at_checkout() ); - $this->assertTrue( $sepa_method->is_enabled_at_checkout() ); + $this->assertFalse( $sepa_method->is_enabled_at_checkout() ); $this->assertFalse( $p24_method->is_enabled_at_checkout() ); $this->assertFalse( $ideal_method->is_enabled_at_checkout() ); $this->assertFalse( $becs_method->is_enabled_at_checkout() ); @@ -2031,7 +2044,7 @@ public function test_save_option_for_sepa_debit() { $this->mock_customer_service ); - $this->assertSame( $upe_checkout->get_payment_fields_js_config()['paymentMethodsConfig'][ Payment_Method::SEPA ]['showSaveOption'], true ); + $this->assertSame( $upe_checkout->get_payment_fields_js_config()['paymentMethodsConfig'][ Payment_Method::SEPA ]['showSaveOption'], false ); } public function test_remove_link_payment_method_if_card_disabled() { @@ -2178,7 +2191,7 @@ function ( $payment_method ) { 'countries' => [], 'upePaymentIntentData' => null, 'upeSetupIntentData' => null, - 'testingInstructions' => '<strong>Test mode:</strong> use the test VISA card 4242424242424242 with any expiry date and CVC. Other payment methods may redirect to a Stripe test page to authorize payment. More test card numbers are listed <a href="https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/testing/#test-cards" target="_blank">here</a>.', + 'testingInstructions' => '<strong>Test mode:</strong> use the test VISA card 4242424242424242 with any expiry date and CVC. Other payment methods may redirect to a Stripe test page to authorize payment. More test card numbers are listed <a href="https://woocommerce.com/document/woopayments/testing-and-troubleshooting/testing/#test-cards" target="_blank">here</a>.', 'forceNetworkSavedCards' => false, ], 'link' => [ diff --git a/tests/unit/src/ContainerTest.php b/tests/unit/src/ContainerTest.php index 5198d815358..370e9d08311 100644 --- a/tests/unit/src/ContainerTest.php +++ b/tests/unit/src/ContainerTest.php @@ -73,6 +73,18 @@ protected function setUp(): void { $this->test_sut = wcpay_get_test_container(); } + /** + * Cleans up global replacements after the class. + * + * Without this, other `src` tests will fail. + */ + public static function tearDownAfterClass(): void { + parent::tearDownAfterClass(); + + $GLOBALS['wcpay_container'] = null; + $GLOBALS['wcpay_test_container'] = null; + } + /** * Tests the `wcpay_get_container` function. */ diff --git a/tests/unit/src/Internal/Payment/FactorTest.php b/tests/unit/src/Internal/Payment/FactorTest.php new file mode 100644 index 00000000000..768718d860b --- /dev/null +++ b/tests/unit/src/Internal/Payment/FactorTest.php @@ -0,0 +1,45 @@ +<?php +/** + * Class FactorTest + * + * @package WooCommerce\Payments + */ + +namespace WCPay\Tests\Internal\Payment; + +use WCPAY_UnitTestCase; +use WCPay\Internal\Payment\Factor; + +/** + * Test class for the Factor enum. + */ +class FactorTest extends WCPAY_UnitTestCase { + /** + * Tests that all factors are returned correctly. + * + * This test is meant to make sure that a typo with + * factors doesn't randomly break the payment process. + */ + public function test_get_all_factors() { + // Factors here are string intentionally. + $factors = [ + 'NEW_PAYMENT_PROCESS', + 'NO_PAYMENT', + 'USE_SAVED_PM', + 'SAVE_PM', + 'SUBSCRIPTION_SIGNUP', + 'SUBSCRIPTION_RENEWAL', + 'POST_AUTHENTICATION', + 'WOOPAY_ENABLED', + 'WOOPAY_PAYMENT', + 'WCPAY_SUBSCRIPTION_SIGNUP', + 'IPP_CAPTURE', + 'STRIPE_LINK', + 'DEFERRED_INTENT_SPLIT_UPE', + 'PAYMENT_REQUEST', + ]; + + $result = Factor::get_all_factors(); + $this->assertEquals( $factors, $result ); + } +} diff --git a/tests/unit/src/Internal/Payment/PaymentRequestTest.php b/tests/unit/src/Internal/Payment/PaymentRequestTest.php new file mode 100644 index 00000000000..0cde154fa20 --- /dev/null +++ b/tests/unit/src/Internal/Payment/PaymentRequestTest.php @@ -0,0 +1,247 @@ +<?php +/** + * Class PaymentRequestTest + * + * @package WooCommerce\Payments + */ + +namespace WCPay\Tests\Internal\Payment; + +use PHPUnit\Framework\MockObject\MockObject; +use WC_Payment_Token; +use WC_Payment_Tokens; +use WCPay\Internal\Payment\PaymentMethod\NewPaymentMethod; +use WCPay\Internal\Payment\PaymentMethod\SavedPaymentMethod; +use WCPay\Internal\Payment\PaymentRequest; +use WCPay\Internal\Payment\PaymentRequestException; +use WCPay\Internal\Proxy\LegacyProxy; +use WCPAY_UnitTestCase; + +/** + * Tests for class PaymentRequestUtilTest + */ +class PaymentRequestTest extends WCPAY_UnitTestCase { + /** + * System under test. + * + * @var PaymentRequest + */ + private $sut; + + /** + * Mock legacy proxy. + * + * @var LegacyProxy|MockObject + */ + private $mock_legacy_proxy; + + public function setUp(): void { + parent::setUp(); + $this->mock_legacy_proxy = $this->createMock( LegacyProxy::class ); + } + + /** + * @dataProvider provider_text_string_param + */ + public function test_get_fraud_prevention_token( ?string $value, ?string $expected ) { + $request = is_null( $value ) ? [] : [ 'wcpay-fraud-prevention-token' => $value ]; + $this->sut = new PaymentRequest( $this->mock_legacy_proxy, $request ); + $this->assertSame( $expected, $this->sut->get_fraud_prevention_token() ); + } + + /** + * @dataProvider provider_text_string_param + */ + public function test_get_woopay_intent_id( ?string $value, ?string $expected ) { + $request = is_null( $value ) ? [] : [ 'platform-checkout-intent' => $value ]; + $this->sut = new PaymentRequest( $this->mock_legacy_proxy, $request ); + $this->assertSame( $expected, $this->sut->get_woopay_intent_id() ); + } + + /** + * @dataProvider provider_text_string_param + */ + public function test_get_intent_id( ?string $value, ?string $expected ) { + $request = is_null( $value ) ? [] : [ 'intent_id' => $value ]; + $this->sut = new PaymentRequest( $this->mock_legacy_proxy, $request ); + $this->assertSame( $expected, $this->sut->get_intent_id() ); + } + + /** + * @dataProvider provider_text_string_param + */ + public function test_get_payment_method_id( ?string $value, ?string $expected ) { + $request = is_null( $value ) ? [] : [ 'payment_method_id' => $value ]; + $this->sut = new PaymentRequest( $this->mock_legacy_proxy, $request ); + $this->assertSame( $expected, $this->sut->get_payment_method_id() ); + } + + public function provider_text_string_param(): array { + return [ + 'Param is not set' => [ + 'value' => null, + 'expected' => null, + ], + 'empty string' => [ + 'value' => '', + 'expected' => '', + ], + 'normal string' => [ + 'value' => 'String-with-dash_and_underscore', + 'expected' => 'String-with-dash_and_underscore', + ], + 'string will be changed after sanitization' => [ + 'value' => " \n<tag>String-with_special_chars__@.#$%^&*()", + 'expected' => 'String-with_special_chars__@.#$%^&*()', + ], + ]; + } + + public function provider_text_string_for_bool_representation(): array { + return [ + 'Param is not set' => [ + 'value' => null, + 'expected' => false, + ], + 'empty string' => [ + 'value' => '', + 'expected' => true, + ], + 'any string' => [ + 'value' => 'any string', + 'expected' => true, + ], + ]; + } + + /** + * @dataProvider provider_text_string_for_bool_representation + */ + public function test_is_woopay_preflight_check( ?string $value, bool $expected ) { + $request = is_null( $value ) ? [] : [ 'is-woopay-preflight-check' => $value ]; + $this->sut = new PaymentRequest( $this->mock_legacy_proxy, $request ); + $this->assertSame( $expected, $this->sut->is_woopay_preflight_check() ); + } + + /** + * @dataProvider provider_test_order_id + */ + public function test_get_order_id( ?string $value, ?int $expected ) { + $request = is_null( $value ) ? [] : [ 'order_id' => $value ]; + $this->sut = new PaymentRequest( $this->mock_legacy_proxy, $request ); + $this->assertSame( $expected, $this->sut->get_order_id() ); + } + + public function provider_test_order_id(): array { + return [ + 'Param is not set' => [ + 'value' => null, + 'expected' => null, + ], + 'normal id' => [ + 'value' => '123', + 'expected' => 123, + ], + 'id will be sanitized' => [ + 'value' => '123abc', + 'expected' => 123, + ], + ]; + } + + /** + * @dataProvider provider_get_payment_method_throw_exception_due_to_miss_payment_method_param + */ + public function test_get_payment_method_throw_exception_due_to_miss_payment_method_param( array $request ) { + $this->expectException( PaymentRequestException::class ); + $this->expectExceptionMessage( 'WooPayments is not used during checkout.' ); + $this->sut = new PaymentRequest( $this->mock_legacy_proxy, $request ); + $this->sut->get_payment_method(); + } + + public function provider_get_payment_method_throw_exception_due_to_miss_payment_method_param(): array { + return [ + 'empty payment_method param' => [ [ 'payment_method' => '' ] ], + 'not WooPayments method' => [ + [ 'payment_method' => 'NOT_woocommerce_payments' ], + ], + ]; + } + + public function test_get_payment_throw_exception_due_to_invalid_token_id() { + $request = [ + 'payment_method' => 'woocommerce_payments', + 'wc-woocommerce_payments-payment-token' => 123456, + ]; + $this->sut = new PaymentRequest( + $this->mock_legacy_proxy, + $request + ); + + $this->mock_legacy_proxy->expects( $this->once() ) + ->method( 'call_static' ) + ->with( WC_Payment_Tokens::class, 'get', 123456 ) + ->willReturn( null ); + $this->expectException( PaymentRequestException::class ); + $this->expectExceptionMessage( 'Invalid saved payment method (token) ID' ); + + $this->sut->get_payment_method(); + } + + public function test_get_payment_return_saved_payment_method() { + // Prepare. + $request = [ + 'payment_method' => 'woocommerce_payments', + 'wc-woocommerce_payments-payment-token' => 123456, + ]; + $this->sut = new PaymentRequest( + $this->mock_legacy_proxy, + $request + ); + $mock_token = $this->createMock( WC_Payment_Token::class ); + $this->mock_legacy_proxy->expects( $this->once() ) + ->method( 'call_static' ) + ->with( WC_Payment_Tokens::class, 'get', 123456 ) + ->willReturn( $mock_token ); + + // Act. + $pm = $this->sut->get_payment_method(); + + // Assert: correct type of instance. + $this->assertInstanceOf( SavedPaymentMethod::class, $pm ); + + // Assert: the same payment method string saved in the token object. + $mock_token->expects( $this->once() ) + ->method( 'get_token' ) + ->willReturn( 'pm_saved_method' ); + $this->assertSame( $pm->get_id(), 'pm_saved_method' ); + } + + public function test_get_payment_return_new_payment_method() { + $request = [ + 'payment_method' => 'woocommerce_payments', + 'wcpay-payment-method' => 'pm_mock', + ]; + $this->sut = new PaymentRequest( + $this->mock_legacy_proxy, + $request + ); + $pm = $this->sut->get_payment_method(); + + $this->assertInstanceOf( NewPaymentMethod::class, $pm ); + $this->assertSame( 'pm_mock', $pm->get_id() ); + } + + public function test_get_payment_method_throw_exception_due_to_no_payment_method_attached() { + $request = [ 'payment_method' => 'woocommerce_payments' ]; + $this->sut = new PaymentRequest( + $this->mock_legacy_proxy, + $request + ); + + $this->expectException( PaymentRequestException::class ); + $this->expectExceptionMessage( 'No valid payment method was selected.' ); + + $this->sut->get_payment_method(); + } +} diff --git a/tests/unit/src/Internal/Payment/RouterTest.php b/tests/unit/src/Internal/Payment/RouterTest.php new file mode 100644 index 00000000000..818bf8c33e4 --- /dev/null +++ b/tests/unit/src/Internal/Payment/RouterTest.php @@ -0,0 +1,274 @@ +<?php +/** + * Class RouterTest + * + * @package WooCommerce\Payments + */ + +namespace WCPay\Tests\Internal\Payment; + +use WCPAY_UnitTestCase; +use PHPUnit\Framework\MockObject\MockObject; +use WCPay\Core\Server\Request\Get_Payment_Process_Factors; +use WCPay\Database_Cache; +use WCPay\Exceptions\API_Exception; +use WCPay\Internal\Payment\Factor; +use WCPay\Internal\Payment\Router; + +/** + * New payment process as a router router test. + */ +class RouterTest extends WCPAY_UnitTestCase { + /** + * Service under test. + * + * @var Router + */ + private $sut; + + /** + * Database cache mock. + * + * @var Database_Cache|MockObject + */ + private $mock_db_cache; + + /** + * Tests set-up. + */ + public function setUp(): void { + parent::setUp(); + + $this->mock_db_cache = $this->createMock( Database_Cache::class ); + $this->sut = new Router( $this->mock_db_cache ); + } + + /** + * Tests that the router returns false if a factor is **not present** in the account cache. + */ + public function test_should_use_new_payment_process_returns_false_with_missing_factor() { + $this->mock_db_cache_factors( [] ); + + $result = $this->sut->should_use_new_payment_process( [ Factor::USE_SAVED_PM() ] ); + $this->assertFalse( $result ); + } + + /** + * Tests that the router returns false if a factor is **false** in the account cache. + */ + public function test_should_use_new_payment_process_returns_false_with_unavailable_factor() { + $this->mock_db_cache_factors( [ Factor::USE_SAVED_PM => false ] ); + + $result = $this->sut->should_use_new_payment_process( [ Factor::USE_SAVED_PM() ] ); + $this->assertFalse( $result ); + } + + /** + * Tests that the router returns true when a factor is both present, and true in the account cache. + */ + public function test_should_use_new_payment_process_returns_true_with_available_factor() { + $this->mock_db_cache_factors( [ Factor::USE_SAVED_PM => true ] ); + + $result = $this->sut->should_use_new_payment_process( [ Factor::USE_SAVED_PM() ] ); + $this->assertTrue( $result ); + } + + /** + * Tests that the router handles multiple flags properly, + * and returns false in case any of them is not available. + */ + public function test_should_use_new_payment_process_with_multiple_factors_returns_false() { + $this->mock_db_cache_factors( + [ + Factor::USE_SAVED_PM => true, + Factor::SUBSCRIPTION_SIGNUP => false, + Factor::WOOPAY_ENABLED => true, + Factor::PAYMENT_REQUEST => false, + ] + ); + + $result = $this->sut->should_use_new_payment_process( + [ + Factor::USE_SAVED_PM(), + Factor::SUBSCRIPTION_SIGNUP(), + Factor::WOOPAY_ENABLED(), + ] + ); + $this->assertFalse( $result ); + } + + /** + * Tests that the router handles multiple flags properly, + * and returns true when all factors are present. + */ + public function test_should_use_new_payment_process_with_multiple_factors_returns_true() { + $this->mock_db_cache_factors( + [ + Factor::USE_SAVED_PM => true, + Factor::SUBSCRIPTION_SIGNUP => true, + Factor::WOOPAY_ENABLED => true, + Factor::PAYMENT_REQUEST => false, + ] + ); + + $result = $this->sut->should_use_new_payment_process( + [ + Factor::USE_SAVED_PM(), + Factor::SUBSCRIPTION_SIGNUP(), + Factor::WOOPAY_ENABLED(), + ] + ); + $this->assertTrue( $result ); + } + + /** + * Check that `get_allowed_factors` returns the factors, provided by the cache. + */ + public function test_get_allowed_factors_returns_factors() { + $cached_factors = [ + Factor::SAVE_PM => true, + Factor::SUBSCRIPTION_SIGNUP => false, + ]; + $processed_factors = [ Factor::SAVE_PM() ]; + + $this->mock_db_cache_factors( $cached_factors, false ); + + $result = $this->sut->get_allowed_factors(); + + $this->assertIsArray( $result ); + $this->assertSame( $processed_factors, $result ); + } + + /** + * Ensures that `get_allowed_factors` returns an array, even with broken cache. + */ + public function test_get_allowed_factors_returns_empty_array() { + // Return nothing to force an empty array. + $this->mock_db_cache_factors( null, false ); + + $result = $this->sut->get_allowed_factors(); + + $this->assertIsArray( $result ); + $this->assertEmpty( $result ); + } + + /** + * Confirms that `get_allowed_factors` allows filters to work. + */ + public function test_get_allowed_factors_allows_filters() { + $cached_factors = [ + Factor::SAVE_PM => true, + Factor::SUBSCRIPTION_SIGNUP => false, + ]; + $replaced_factors = [ + Factor::NO_PAYMENT(), + ]; + $this->mock_db_cache_factors( $cached_factors, false ); + + $filter_cb = function() use ( $replaced_factors ) { + return $replaced_factors; + }; + add_filter( 'wcpay_new_payment_process_enabled_factors', $filter_cb ); + + $result = $this->sut->get_allowed_factors(); + + $this->assertIsArray( $result ); + $this->assertSame( $replaced_factors, $result ); + + remove_filter( 'wcpay_new_payment_process_enabled_factors', $filter_cb ); + } + + /** + * Verify that `is_valid_cache` returns false with a non-array. + */ + public function test_is_valid_cache_requires_array() { + $this->assertFalse( $this->sut->is_valid_cache( false ) ); + } + + /** + * Verify that `is_valid_cache` returns false with incorrect arrays. + */ + public function test_is_valid_cache_requires_base_factor() { + $cache = [ Factor::NO_PAYMENT => true ]; + $this->assertFalse( $this->sut->is_valid_cache( $cache ) ); + } + + /** + * Verify that `is_valid_cache` accepts well-formed data. + */ + public function test_is_valid_cache_with_well_formed_data() { + $cache = [ Factor::NEW_PAYMENT_PROCESS => true ]; + $this->assertTrue( $this->sut->is_valid_cache( $cache ) ); + } + + /** + * + */ + public function test_get_cached_factors_populates_cache() { + $request_response = [ + Factor::NEW_PAYMENT_PROCESS => true, + ]; + $processed_factors = [ Factor::NEW_PAYMENT_PROCESS() ]; + + $this->mock_wcpay_request( Get_Payment_Process_Factors::class, 1, null, $request_response ); + + $this->mock_db_cache->expects( $this->once() ) + ->method( 'get_or_add' ) + ->with( + Database_Cache::PAYMENT_PROCESS_FACTORS_KEY, + $this->callback( + function ( $cb ) use ( $request_response ) { + return $request_response === $cb(); + } + ), + [ $this->sut, 'is_valid_cache' ], + false + ) + ->willReturn( $request_response ); + + $result = $this->sut->get_allowed_factors(); + $this->assertSame( $processed_factors, $result ); + } + + /** + * Ensures that a server error would handle exceptions correctly. + */ + public function test_get_cached_factors_handles_exceptions() { + $generator = function( $cb ) { + $this->mock_wcpay_request( Get_Payment_Process_Factors::class ) + ->expects( $this->once() ) + ->method( 'format_response' ) + ->will( $this->throwException( new API_Exception( 'Does not work', 'forced', 1234 ) ) ); + + $result = $cb(); + return false === $result; + }; + + $this->mock_db_cache->expects( $this->once() ) + ->method( 'get_or_add' ) + ->with( + Database_Cache::PAYMENT_PROCESS_FACTORS_KEY, + $this->callback( $generator ) + ) + ->willReturn( false ); + + $this->assertEmpty( $this->sut->get_allowed_factors() ); + } + + /** + * Simulates specific factors, being returned by `Database_Cache`. + * + * @param array|null $factors The factors to simulate. + * @param bool $add_base Whether to add the base `NEW_PAYMENT_PROCESS` factor. + */ + private function mock_db_cache_factors( array $factors = null, bool $add_base = true ) { + if ( $add_base && ! isset( $factors[ Factor::NEW_PAYMENT_PROCESS ] ) ) { + $factors[ Factor::NEW_PAYMENT_PROCESS ] = true; + } + + $this->mock_db_cache->expects( $this->once() ) + ->method( 'get_or_add' ) + ->with( Database_Cache::PAYMENT_PROCESS_FACTORS_KEY ) + ->willreturn( $factors ); + } +} diff --git a/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php b/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php index d94e5862e52..1518f571285 100644 --- a/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php +++ b/tests/unit/subscriptions/test-class-wc-payments-invoice-service.php @@ -137,6 +137,10 @@ public function test_maybe_record_invoice_payment() { $mock_order = WC_Helper_Order::create_order(); $mock_subscription = new WC_Subscription(); + $mock_subscription->payment_method = 'woocommerce_payments'; + $mock_subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, 'sub_123abc' ); + $mock_subscription->save(); + // With the following calls to `maybe_record_first_invoice_payment()`, we only expect 2 calls (see Positive Cases) to result in an API call. $this->mock_api_client->expects( $this->exactly( 2 ) ) ->method( 'charge_invoice' ) diff --git a/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php b/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php index 1eea00d4e41..490d1c74298 100644 --- a/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php +++ b/tests/unit/subscriptions/test-class-wc-payments-subscription-service.php @@ -171,6 +171,9 @@ public function test_create_subscription() { ], ], ], + 'metadata' => [ + 'subscription_source' => 'woo_subscriptions', + ], ]; $this->assertNotEquals( $mock_subscription->get_meta( self::SUBSCRIPTION_ID_META_KEY ), $mock_wcpay_subscription_id ); @@ -400,6 +403,7 @@ public function test_update_wcpay_subscription_payment_method() { $token = WC_Helper_Token::create_token( $mock_wcpay_token_id, 1 ); $subscription->set_parent( $mock_order ); + $subscription->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID ); $subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, $mock_wcpay_subscription_id ); WC_Subscriptions::set_wcs_get_subscription( @@ -612,6 +616,9 @@ public function test_maybe_attempt_payment_for_subscription() { $mock_pending_invoice_id = 'wcpay_pending_invoice_idtest123'; $mock_subscription->update_meta_data( WC_Payments_Invoice_Service_Test::PENDING_INVOICE_ID_KEY, $mock_pending_invoice_id ); + $mock_subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, 'sub_123' ); + $mock_subscription->payment_method = 'woocommerce_payments'; + $mock_subscription->save(); WC_Subscriptions::set_wcs_is_subscription( function ( $subscription ) { diff --git a/tests/unit/test-class-base-constant.php b/tests/unit/test-class-base-constant.php index 7d390c0b4bb..c4ebf7e25cf 100644 --- a/tests/unit/test-class-base-constant.php +++ b/tests/unit/test-class-base-constant.php @@ -12,6 +12,11 @@ */ class Base_Constant_Test extends WCPAY_UnitTestCase { + public function test_base_constant_retun_single_object_for_multiple_same_static_calls() { + $instance_1 = Payment_Method::BASC(); + $instance_2 = Payment_Method::BASC(); + $this->assertTrue( $instance_1 === $instance_2 ); + } public function test_base_constant_will_create_constant() { $class = Payment_Method::BASC(); $this->assertInstanceOf( Payment_Method::class, $class ); @@ -52,4 +57,5 @@ public function test_class_will_throw_exception_if_searched_by_value_that_does_n $this->expectException( \InvalidArgumentException::class ); Payment_Method::search( 'foo' ); } + } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php index bef80ba1878..74a4ad79b29 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php @@ -445,15 +445,18 @@ function( $metadata ) { } public function test_saved_card_zero_dollar_subscription() { - $order = WC_Helper_Order::create_order( self::USER_ID, 0 ); + $order = WC_Helper_Order::create_order( self::USER_ID, 0 ); + $subscriptions = [ new WC_Subscription() ]; + $subscriptions[0]->set_parent( $order ); + + $this->mock_wcs_order_contains_subscription( true ); + $this->mock_wcs_get_subscriptions_for_order( $subscriptions ); $_POST = [ 'payment_method' => WC_Payment_Gateway_WCPay::GATEWAY_ID, self::TOKEN_REQUEST_KEY => $this->token->get_id(), ]; - $this->mock_wcs_order_contains_subscription( true ); - // The card is already saved and there's no payment needed, so no Setup Intent needs to be created. $request = $this->mock_wcpay_request( Create_And_Confirm_Setup_Intention::class, 0 ); @@ -463,9 +466,6 @@ public function test_saved_card_zero_dollar_subscription() { ->expects( $this->never() ) ->method( 'add_payment_method_to_user' ); - $subscriptions = [ WC_Helper_Order::create_order( self::USER_ID ) ]; - $this->mock_wcs_get_subscriptions_for_order( $subscriptions ); - $result = $this->mock_wcpay_gateway->process_payment( $order->get_id() ); $result_order = wc_get_order( $order->get_id() ); diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php index 68c43ee6355..b888bf80cab 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php @@ -76,7 +76,7 @@ public function test_maybe_init_subscriptions_with_wcs_enabled() { $this->assertSame( $expected, $this->mock_wcpay_subscriptions_trait->supports ); } - public function test_maybe_init_subscriptions_with_wcs_disabled() { + public function test_maybe_init_subscriptions_with_stripe_billing_enabled() { $this->mock_wcpay_subscriptions_trait ->method( 'is_subscriptions_enabled' ) ->willReturn( true ); @@ -85,6 +85,8 @@ public function test_maybe_init_subscriptions_with_wcs_disabled() { ->method( 'is_subscriptions_plugin_active' ) ->willReturn( false ); + update_option( '_wcpay_feature_stripe_billing', '1' ); + $this->mock_wcpay_subscriptions_trait->maybe_init_subscriptions(); $expected = [ @@ -100,5 +102,7 @@ public function test_maybe_init_subscriptions_with_wcs_disabled() { ]; $this->assertSame( $expected, $this->mock_wcpay_subscriptions_trait->supports ); + + delete_option( '_wcpay_feature_stripe_billing' ); } } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 625a49ed56e..9a675bdcaca 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -21,6 +21,9 @@ use WCPay\Exceptions\Amount_Too_Small_Exception; use WCPay\Exceptions\API_Exception; use WCPay\Fraud_Prevention\Fraud_Prevention_Service; +use WCPay\Internal\Payment\Factor; +use WCPay\Internal\Payment\Router; +use WCPay\Internal\Service\PaymentProcessingService; use WCPay\Payment_Information; use WCPay\WooPay\WooPay_Utilities; use WCPay\Session_Rate_Limiter; @@ -211,6 +214,20 @@ public function tear_down() { // Fall back to an US store. update_option( 'woocommerce_store_postcode', '94110' ); $this->wcpay_gateway->update_option( 'saved_cards', 'yes' ); + + // Some tests simulate payment method parameters. + $payment_method_keys = [ + 'payment_method', + 'wc-woocommerce_payments-payment-token', + 'wc-woocommerce_payments-new-payment-method', + ]; + foreach ( $payment_method_keys as $key ) { + // phpcs:disable WordPress.Security.NonceVerification.Missing + if ( isset( $_POST[ $key ] ) ) { + unset( $_POST[ $key ] ); + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + } } public function test_attach_exchange_info_to_order_with_no_conversion() { @@ -1314,13 +1331,17 @@ public function test_capture_charge_metadata() { ); $merged_metadata = [ - 'customer_name' => 'Test', - 'customer_email' => $order->get_billing_email(), - 'site_url' => esc_url( get_site_url() ), - 'order_id' => $order->get_id(), - 'order_number' => $order->get_order_number(), - 'order_key' => $order->get_order_key(), - 'payment_type' => Payment_Type::SINGLE(), + 'customer_name' => 'Test', + 'customer_email' => $order->get_billing_email(), + 'site_url' => esc_url( get_site_url() ), + 'order_id' => $order->get_id(), + 'order_number' => $order->get_order_number(), + 'order_key' => $order->get_order_key(), + 'payment_type' => Payment_Type::SINGLE(), + 'gateway_type' => 'classic', + 'checkout_type' => '', + 'client_version' => WCPAY_VERSION_NUMBER, + 'subscription_payment' => 'no', ]; $request = $this->mock_wcpay_request( Get_Intention::class, 1, $intent_id ); @@ -2359,6 +2380,287 @@ public function test_no_payment_is_processed_for_woopay_preflight_check_request( $response = $mock_wcpay_gateway->process_payment( $order->get_id() ); } + public function test_should_use_new_process_requires_dev_mode() { + $mock_router = $this->createMock( Router::class ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $order = WC_Helper_Order::create_order(); + + // Assert: The router is never called. + $mock_router->expects( $this->never() ) + ->method( 'should_use_new_payment_process' ); + + $this->assertFalse( $this->wcpay_gateway->should_use_new_process( $order ) ); + } + + public function test_should_use_new_process_returns_false_if_feature_unavailable() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $mock_router = $this->createMock( Router::class ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $order = WC_Helper_Order::create_order(); + + // Assert: Feature returns false. + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->willReturn( false ); + + // Act: Call the method. + $result = $this->wcpay_gateway->should_use_new_process( $order ); + $this->assertFalse( $result ); + } + + public function test_should_use_new_process_uses_the_new_process() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $mock_router = $this->createMock( Router::class ); + $mock_service = $this->createMock( PaymentProcessingService::class ); + $order = WC_Helper_Order::create_order(); + + wcpay_get_test_container()->replace( Router::class, $mock_router ); + wcpay_get_test_container()->replace( PaymentProcessingService::class, $mock_service ); + + // Assert: Feature returns false. + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->willReturn( true ); + + // Act: Call the method. + $result = $this->wcpay_gateway->should_use_new_process( $order ); + $this->assertTrue( $result ); + } + + public function test_should_use_new_process_adds_base_factor() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order( 1, 0 ); + + $this->expect_router_factor( Factor::NEW_PAYMENT_PROCESS(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_no_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order( 1, 0 ); + + $this->expect_router_factor( Factor::NO_PAYMENT(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_no_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + $order->set_total( 10 ); + $order->save(); + + $this->expect_router_factor( Factor::NO_PAYMENT(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_no_payment_when_saving_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order( 1, 0 ); + + // Simulate a payment method being saved to force payment processing. + $_POST['wc-woocommerce_payments-new-payment-method'] = 'pm_XYZ'; + + $this->expect_router_factor( Factor::NO_PAYMENT(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_use_saved_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + $token = WC_Helper_Token::create_token( 'pm_XYZ' ); + + // Simulate that a saved token is being used. + $_POST['payment_method'] = 'woocommerce_payments'; + $_POST['wc-woocommerce_payments-payment-token'] = $token->get_id(); + + $this->expect_router_factor( Factor::USE_SAVED_PM(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_use_saved_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + // Simulate that a saved token is being used. + $_POST['payment_method'] = 'woocommerce_payments'; + $_POST['wc-woocommerce_payments-payment-token'] = 'new'; + + $this->expect_router_factor( Factor::USE_SAVED_PM(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_save_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + $_POST['wc-woocommerce_payments-new-payment-method'] = '1'; + + $this->expect_router_factor( Factor::SAVE_PM(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_save_pm_for_subscription() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_true'; + + $this->expect_router_factor( Factor::SAVE_PM(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_save_pm() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + $token = WC_Helper_Token::create_token( 'pm_XYZ' ); + + // Simulate that a saved token is being used. + $_POST['wc-woocommerce_payments-new-payment-method'] = '1'; + $_POST['payment_method'] = 'woocommerce_payments'; + $_POST['wc-woocommerce_payments-payment-token'] = $token->get_id(); + + $this->expect_router_factor( Factor::SAVE_PM(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_subscription_signup() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_true'; + + $this->expect_router_factor( Factor::SUBSCRIPTION_SIGNUP(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_subscription_signup() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_false'; + + $this->expect_router_factor( Factor::SUBSCRIPTION_SIGNUP(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_positive_woopay_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + $_POST['platform-checkout-intent'] = 'pi_ZYX'; + + $this->expect_router_factor( Factor::WOOPAY_PAYMENT(), true ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_should_use_new_process_determines_negative_woopay_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing + unset( $_POST['platform-checkout-intent'] ); + + $this->expect_router_factor( Factor::WOOPAY_PAYMENT(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + /** + * Testing the positive WCPay subscription signup factor is not possible, + * as the check relies on the existence of the `WC_Subscriptions` class + * through an un-mockable method, and the class simply exists. + */ + public function test_should_use_new_process_determines_negative_wcpay_subscription_signup() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $order = WC_Helper_Order::create_order(); + + WC_Subscriptions::$wcs_order_contains_subscription = '__return_true'; + add_filter( 'wcpay_is_wcpay_subscriptions_enabled', '__return_true' ); + + $this->expect_router_factor( Factor::WCPAY_SUBSCRIPTION_SIGNUP(), false ); + $this->wcpay_gateway->should_use_new_process( $order ); + } + + public function test_new_process_payment() { + // The new payment process is only accessible in dev mode. + WC_Payments::mode()->dev(); + + $mock_service = $this->createMock( PaymentProcessingService::class ); + $mock_router = $this->createMock( Router::class ); + $order = WC_Helper_Order::create_order(); + $mock_response = [ 'success' => 'maybe' ]; + + wcpay_get_test_container()->replace( PaymentProcessingService::class, $mock_service ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->willReturn( true ); + + // Assert: The new service is called. + $mock_service->expects( $this->once() ) + ->method( 'process_payment' ) + ->with( $order->get_id() ) + ->willReturn( $mock_response ); + + $result = $this->wcpay_gateway->process_payment( $order->get_id() ); + $this->assertSame( $mock_response, $result ); + } + + /** + * Sets up the expectation for a certain factor for the new payment + * process to be either set or unset. + * + * @param Factor $factor_name Factor constant. + * @param bool $value Expected value. + */ + private function expect_router_factor( $factor_name, $value ) { + $mock_router = $this->createMock( Router::class ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $checker = function( $factors ) use ( $factor_name, $value ) { + $is_in_array = in_array( $factor_name, $factors, true ); + return $value ? $is_in_array : ! $is_in_array; + }; + + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->with( $this->callback( $checker ) ); + } + /** * Mocks Fraud_Prevention_Service. * diff --git a/tests/unit/test-class-wc-payments-incentives-service.php b/tests/unit/test-class-wc-payments-incentives-service.php index cb36d1bb341..70df9bc262b 100644 --- a/tests/unit/test-class-wc-payments-incentives-service.php +++ b/tests/unit/test-class-wc-payments-incentives-service.php @@ -73,6 +73,7 @@ public function tear_down() { public function test_filters_registered_properly() { $this->assertNotFalse( has_action( 'admin_menu', [ $this->incentives_service, 'add_payments_menu_badge' ] ) ); $this->assertNotFalse( has_filter( 'woocommerce_admin_allowed_promo_notes', [ $this->incentives_service, 'allowed_promo_notes' ] ) ); + $this->assertNotFalse( has_filter( 'woocommerce_admin_woopayments_onboarding_task_badge', [ $this->incentives_service, 'onboarding_task_badge' ] ) ); } public function test_add_payments_menu_badge_without_incentive() { @@ -111,6 +112,32 @@ public function test_allowed_promo_notes_with_incentive() { $this->assertContains( $this->mock_incentive_data['incentive']['id'], $promo_notes ); } + public function test_onboarding_task_badge_without_incentive() { + $this->mock_database_cache_with(); + + $badge = $this->incentives_service->onboarding_task_badge( '' ); + + $this->assertEmpty( $badge ); + } + + public function test_onboarding_task_badge_with_incentive_no_task_badge() { + $this->mock_database_cache_with( $this->mock_incentive_data ); + + $badge = $this->incentives_service->onboarding_task_badge( '' ); + + $this->assertEmpty( $badge ); + } + + public function test_onboarding_task_badge_with_incentive_and_task_badge() { + $incentive_data = $this->mock_incentive_data; + $incentive_data['incentive']['task_badge'] = 'task_badge'; + $this->mock_database_cache_with( $incentive_data ); + + $badge = $this->incentives_service->onboarding_task_badge( '' ); + + $this->assertEquals( $badge, 'task_badge' ); + } + public function test_get_cached_connect_incentive_non_supported_country() { add_filter( 'woocommerce_countries_base_country', diff --git a/tests/unit/test-class-woopay-tracker.php b/tests/unit/test-class-woopay-tracker.php index 9a8a6733b53..408d9cb56f5 100644 --- a/tests/unit/test-class-woopay-tracker.php +++ b/tests/unit/test-class-woopay-tracker.php @@ -5,6 +5,7 @@ * @package WooCommerce\Payments\Tests */ +use PHPUnit\Framework\MockObject\MockObject; use WCPay\WooPay_Tracker; /** @@ -24,6 +25,11 @@ class WooPay_Tracker_Test extends WCPAY_UnitTestCase { */ private $http_client_stub; + /** + * @var WC_Payments_Account|MockObject + */ + private $mock_account; + /** * Pre-test setup */ @@ -37,6 +43,10 @@ public function set_up() { $this->_cache = WC_Payments::get_database_cache(); $this->mock_cache = $this->createMock( WCPay\Database_Cache::class ); WC_Payments::set_database_cache( $this->mock_cache ); + + $this->mock_account = $this->getMockBuilder( WC_Payments_Account::class ) + ->disableOriginalConstructor() + ->getMock(); } public function tear_down() { @@ -47,6 +57,8 @@ public function tear_down() { } public function test_tracks_obeys_woopay_flag() { + $this->set_account_connected( true ); + WC_Payments::set_account_service( $this->mock_account ); $this->set_is_woopay_eligible( false ); $this->assertFalse( $this->tracker->should_enable_tracking( null, null ) ); } @@ -54,6 +66,8 @@ public function test_tracks_obeys_woopay_flag() { public function test_does_not_track_admin_pages() { wp_set_current_user( 1 ); $this->set_is_woopay_eligible( true ); + $this->set_account_connected( true ); + WC_Payments::set_account_service( $this->mock_account ); $this->set_is_admin( true ); $this->assertFalse( $this->tracker->should_enable_tracking( null, null ) ); } @@ -61,7 +75,9 @@ public function test_does_not_track_admin_pages() { public function test_does_track_non_admins() { global $wp_roles; $this->set_is_woopay_eligible( true ); + $this->set_account_connected( true ); WC_Payments::get_gateway()->update_option( 'platform_checkout', 'yes' ); + WC_Payments::set_account_service( $this->mock_account ); wp_set_current_user( 1 ); $this->set_is_admin( false ); @@ -74,6 +90,16 @@ public function test_does_track_non_admins() { } } + public function test_does_not_track_when_account_not_connected() { + wp_set_current_user( 1 ); + $this->set_is_woopay_eligible( true ); + $this->set_account_connected( false ); + WC_Payments::set_account_service( $this->mock_account ); + $is_admin_event = false; + $track_on_all_stores = true; + $this->assertFalse( $this->tracker->should_enable_tracking( $is_admin_event, $track_on_all_stores ) ); + } + /** * @param bool $is_admin */ @@ -96,9 +122,20 @@ private function set_is_admin( bool $is_admin ) { /** * Cache account details. * - * @param $account + * @param $is_woopay_eligible */ private function set_is_woopay_eligible( $is_woopay_eligible ) { $this->mock_cache->method( 'get' )->willReturn( [ 'platform_checkout_eligible' => $is_woopay_eligible ] ); } + + /** + * Set Stripe Account connections status. + * + * @param $is_stripe_connected + */ + private function set_account_connected( $is_stripe_connected ) { + $this->mock_account + ->method( 'is_stripe_connected' ) + ->willReturn( $is_stripe_connected ); + } } diff --git a/tests/unit/woopay/test-class-woopay-order-status-sync.php b/tests/unit/woopay/test-class-woopay-order-status-sync.php index 2d8124af97c..987680d4bc4 100644 --- a/tests/unit/woopay/test-class-woopay-order-status-sync.php +++ b/tests/unit/woopay/test-class-woopay-order-status-sync.php @@ -43,6 +43,69 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$admin_user = $factory->user->create_and_get( [ 'role' => 'administrator' ] ); } + /** + * Tests that WooPay-specific webhooks are modified as expected. + */ + public function test_woopay_specific_webhook_payload_is_updated() { + wp_set_current_user( self::$admin_user->ID ); + $pre_processing_payload = [ + 'status' => 'publish', + 'average_rating' => '0.00', + 'catalog_visibility' => 'visible', + 'categories' => [ + [ + 'id' => 9, + 'name' => 'Clothing', + 'slug' => 'clothing', + ], + ], + ]; + + $woopay_specific_payload = [ + 'blog_id' => false, + 'order_id' => 1, + 'order_status' => 'publish', + ]; + + wp_set_current_user( self::$admin_user->ID ); + // Create the WebHook with WooPay specific delivery URL. + $this->webhook_sync_mock->maybe_create_woopay_order_webhook(); + + $post_processing_payload = $this->webhook_sync_mock->create_payload( $pre_processing_payload, 'product', 1, 1 ); + $this->assertNotEquals( $pre_processing_payload, $post_processing_payload ); + $this->assertEquals( $post_processing_payload, $woopay_specific_payload ); + + $this->webhook_sync_mock->remove_webhook(); + $this->assertEmpty( WooPay_Order_Status_Sync::get_webhook() ); + } + + /** + * Tests that non WooPay webhooks (e.g. Product Updated, Order created) are filtered out and eventually not modified. + */ + public function test_non_woopay_specific_webhook_payload_remains_unaffected() { + wp_set_current_user( self::$admin_user->ID ); + $pre_processing_payload = [ + 'status' => 'publish', + 'average_rating' => '0.00', + 'catalog_visibility' => 'visible', + 'categories' => [ + [ + 'id' => 9, + 'name' => 'Clothing', + 'slug' => 'clothing', + ], + ], + ]; + + $this->create_non_woopay_specific_webhook(); + + $post_processing_payload = $this->webhook_sync_mock->create_payload( $pre_processing_payload, 'product', 1, 2 ); + $this->assertEquals( $pre_processing_payload, $post_processing_payload ); + + $this->webhook_sync_mock->remove_webhook(); + $this->assertEmpty( WooPay_Order_Status_Sync::get_webhook() ); + } + /** * Tests that the webhook is created succesfuly if the logged in user has the capability manage_woocommerce. */ @@ -83,4 +146,17 @@ private function set_is_woopay_eligible( $is_woopay_eligible ) { $this->mock_cache->method( 'get' )->willReturn( [ 'platform_checkout_eligible' => $is_woopay_eligible ] ); } + private function create_non_woopay_specific_webhook() { + $delivery_url_non_specific_for_woopay = 'some-woocommerce-core-webhook-delivery-url'; + + $webhook = new \WC_Webhook(); + $webhook->set_name( 'WCPay woopay order status sync' ); + $webhook->set_user_id( get_current_user_id() ); + $webhook->set_topic( 'order.status_changed' ); + $webhook->set_secret( wp_generate_password( 50, false ) ); + $webhook->set_delivery_url( $delivery_url_non_specific_for_woopay ); + $webhook->set_status( 'active' ); + $webhook->save(); + } + } diff --git a/tests/unit/woopay/test-class-woopay-session.php b/tests/unit/woopay/test-class-woopay-session.php index d11f5ad2499..647637bb1bc 100644 --- a/tests/unit/woopay/test-class-woopay-session.php +++ b/tests/unit/woopay/test-class-woopay-session.php @@ -14,6 +14,11 @@ * WooPay_Session unit tests. */ class WooPay_Session_Test extends WCPAY_UnitTestCase { + /** + * @var Database_Cache|MockObject + */ + protected $mock_cache; + public function set_up() { parent::set_up(); diff --git a/tsconfig.json b/tsconfig.json index f0df71ed721..3b9efabf45e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "strict": true, // enable strict type checks as a best practice "module": "es6", // specify module code generation "jsx": "react", // use typescript to transpile jsx to js - "target": "es5", // specify ECMAScript target version + "target": "es6", // specify ECMAScript target version "allowJs": true, // allow a partial TypeScript and JavaScript codebase "moduleResolution": "node", "baseUrl": "./client", diff --git a/woocommerce-payments.php b/woocommerce-payments.php index 07d9e9b01f7..f2614acdbcb 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -12,7 +12,7 @@ * WC tested up to: 7.8.0 * Requires at least: 6.0 * Requires PHP: 7.3 - * Version: 6.4.2 + * Version: 6.5.0 * * @package WooCommerce\Payments */ @@ -134,12 +134,6 @@ function () { // Jetpack's Rest_Authentication needs to be initialized even before plugins_loaded. Automattic\Jetpack\Connection\Rest_Authentication::init(); -/** - * Needs to be loaded as soon as possible - * Check https://github.com/Automattic/woocommerce-payments/issues/4759 - */ -\WCPay\WooPay\WooPay_Session::init(); - // Jetpack-config will initialize the modules on "plugins_loaded" with priority 2, so this code needs to be run before that. add_action( 'plugins_loaded', 'wcpay_jetpack_init', 1 ); @@ -150,6 +144,11 @@ function () { function wcpay_init() { require_once WCPAY_ABSPATH . '/includes/class-wc-payments.php'; WC_Payments::init(); + /** + * Needs to be loaded as soon as possible + * Check https://github.com/Automattic/woocommerce-payments/issues/4759 + */ + \WCPay\WooPay\WooPay_Session::init(); } // Make sure this is run *after* WooCommerce has a chance to initialize its packages (wc-admin, etc). That is run with priority 10. @@ -317,7 +316,7 @@ function wcpay_get_jetpack_idc_custom_content(): array { __( 'We’ve detected that you have duplicate sites connected to %s. When Safe Mode is active, payments will not be interrupted. However, some features may not be available until you’ve resolved this issue below. Safe Mode is most frequently activated when you’re transferring your site from one domain to another, or creating a staging site for testing. A site adminstrator can resolve this issue. <safeModeLink>Learn more</safeModeLink>', 'woocommerce-payments' ), 'WooPayments' ), - 'supportURL' => 'https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/safe-mode/', + 'supportURL' => 'https://woocommerce.com/document/woopayments/testing-and-troubleshooting/safe-mode/', 'adminBarSafeModeLabel' => sprintf( /* translators: %s: WooPayments. */ __( '%s Safe Mode', 'woocommerce-payments' ), @@ -328,7 +327,7 @@ function wcpay_get_jetpack_idc_custom_content(): array { __( "<strong>Notice:</strong> It appears that your 'wp-config.php' file might be using dynamic site URL values. Dynamic site URLs could cause %s to enter Safe Mode. <dynamicSiteUrlSupportLink>Learn how to set a static site URL.</dynamicSiteUrlSupportLink>", 'woocommerce-payments' ), 'WooPayments' ), - 'dynamicSiteUrlSupportLink' => 'https://woocommerce.com/document/woocommerce-payments/testing-and-troubleshooting/safe-mode/#dynamic-site-urls', + 'dynamicSiteUrlSupportLink' => 'https://woocommerce.com/document/woopayments/testing-and-troubleshooting/safe-mode/#dynamic-site-urls', ]; $urls = Automattic\Jetpack\Identity_Crisis::get_mismatched_urls();