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 - + ), }, } ) }

- - - { __( - 'Some payment methods cannot be enabled because more information is needed about your account. ', - 'woocommerce-payments' - ) } - - - { __( - 'Learn more about enabling additional payment methods.', - 'woocommerce-payments' - ) } - - + + { __( + 'Some payment methods cannot be enabled because more information is needed about your account. ', + 'woocommerce-payments' + ) } + + + { __( + 'Learn more about enabling additional payment methods.', + 'woocommerce-payments' + ) } + + + ) } ( { 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 = ( {

Test mode: use the test VISA card 4242424242424242 with any expiry date and CVC, or any test card numbers listed{ ' ' } - + here . @@ -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} 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} 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 ( - - { action.label } - - ); - } - - return ( - - ); - } ); - - // 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 ( - - - { icon && ( - - - +

+ { iconToDisplay && ( + + ) } +
+ { children } + { actions.length > 0 && ( +
+ { actions.map( + ( + { + className: buttonCustomClasses, + label, + variant, + onClick, + url, + }, + index + ) => { + let computedVariant = variant; + if ( variant !== 'primary' ) { + computedVariant = ! url + ? 'secondary' + : 'link'; + } + + return ( + + ); + } + ) } +
) } - - { noticeProps.children } - { actions && ( - - { actions } - +
+ { isDismissible && ( +
); -} +}; 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`] = `
-
-
-
- Test notice content -
-
-
-
- -
-
-`; - -exports[`Info BannerNotices renders with dismiss and icon 1`] = ` -
-
+ Custom Icon +
+ Example
-
- -
-
+
-
-
-
- -
-
-`; - -exports[`Info BannerNotices renders with dismiss and icon and actions 1`] = ` -
-
-
-
-
+ - - URL - -
-
+ Submit +
-
`; - -exports[`Info BannerNotices renders without dismiss and icon 1`] = ` -
-
-
-
-
- Test notice content -
-
-
-
-
-
-`; 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( - - ); - expect( container ).toMatchSnapshot(); - } ); +jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) ); - test( 'with dismiss and icon', () => { - const { container } = render( - - ); - 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( Custom Icon } 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 + ); expect( container ).toMatchSnapshot(); } ); - test( 'without dismiss and icon', () => { - const { container } = render( - - ); - expect( container ).toMatchSnapshot(); + it( 'should default to info status', () => { + const { + container: { firstChild }, + } = render( FYI ); + + expect( firstChild ).toHaveClass( 'is-info' ); } ); -} ); -describe( 'Action click triggers callback', () => { - test( 'with dismiss and icon and actions', () => { - const onClickMock = jest.fn(); - const { getByText } = render( - + /***************** */ + + it( 'calls action onClick when clicked', () => { + const onClick = jest.fn(); + render( + + Notice with Action + ); - 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( - + it( 'calls onRemove when dismiss button is clicked', () => { + const onRemove = jest.fn(); + render( + + Dismissible Notice + ); - 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( - - ); + describe( 'useSpokenMessage', () => { + it( 'should speak the given message', () => { + render( FYI ); + + expect( speak ).toHaveBeenCalledWith( 'FYI', 'polite' ); + } ); + + it( 'should speak the given message by implicit politeness by status', () => { + render( Uh oh! ); + + expect( speak ).toHaveBeenCalledWith( 'Uh oh!', 'assertive' ); + } ); + + it( 'should coerce a message to a string', () => { + render( + + With emphasis this time. + + ); + + expect( speak ).toHaveBeenCalledWith( + 'With emphasis this time.', + 'polite' + ); + } ); + + it( 'should not re-speak an effectively equivalent element message', () => { + const { rerender } = render( + Duplicated notice message. + ); + rerender( Duplicated notice message. ); - 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 ( - + { 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 = () => ( - } - 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 ( ), }, } ) } - + ); const NewAccountWaitingPeriodNotice = () => ( - } + icon className="new-account-waiting-period-notice" isDismissible={ false } > @@ -79,18 +73,18 @@ const NewAccountWaitingPeriodNotice = () => ( ), }, } ) } - + ); const NegativeBalanceDepositsPausedNotice = () => ( - } + icon className="negative-balance-deposits-paused-notice" isDismissible={ false } > @@ -110,12 +104,12 @@ const NegativeBalanceDepositsPausedNotice = () => ( ), }, } ) } - + ); /** 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 > = ( { { deposit.id === oldestPendingDepositId && ( - } + 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 ( - } + icon isDismissible={ false } status="warning" > @@ -36,13 +35,13 @@ function SuspendedDepositNotice(): JSX.Element { suspendLink: ( ), }, } ) } - + ); } 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`] = `
@@ -355,7 +355,7 @@ exports[`Deposits Overview information Component Renders 1`] = `
@@ -412,7 +412,7 @@ exports[`Deposits Overview information Component Renders 1`] = ` exports[`Suspended Deposit Notice Renders Component Renders 1`] = `
@@ -442,7 +442,7 @@ exports[`Suspended Deposit Notice Renders Component Renders 1`] = `
@@ -453,7 +453,7 @@ exports[`Suspended Deposit Notice Renders Component Renders 1`] = ` . Learn more 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 = ; } 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: - 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`] = ` Temporarily suspended ( @@ -51,7 +51,7 @@ exports[`DepositsStatus renders blocked status 2`] = ` Temporarily suspended ( 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 ? ( - - - - ) : 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 ? ( + + + + ) : 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 ( - + { __( '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 -} ) => ( - -); +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 = ; + break; + case 'error': + case 'warning': + iconToDisplay = ; + break; + case 'info': + default: + iconToDisplay = ; + 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 ( + + { action.label } + + ); + } + + return ( + + ); + } ); + + return ( + + + { iconToDisplay && ( + + + + ) } + + { children } + { mappedActions && ( + + { mappedActions } + + ) } + + + + ); +} 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`] = ` +
+
+
+
+
+ Test notice content +
+
+
+
+ +
+
+`; + +exports[`Info InlineNotices renders with dismiss and icon 1`] = ` +
+
+
+
+
+ + + + + +
+
+ Test notice content +
+
+
+
+ +
+
+`; + +exports[`Info InlineNotices renders with dismiss and icon and actions 1`] = ` +
+
+
+
+
+ + + + + +
+
+ Test notice content +
+ + + URL + +
+
+
+
+
+ +
+
+`; + +exports[`Info InlineNotices renders with no status and custom icon 1`] = ` +
+
+
+
+
+ + + + + +
+
+ Test notice content +
+
+
+
+ +
+
+`; + +exports[`Info InlineNotices renders without dismiss and icon 1`] = ` +
+
+
+
+
+ Test notice content +
+
+
+
+
+
+`; 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( + + ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'with dismiss and icon', () => { + const { container } = render( + + ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'with dismiss and icon and actions', () => { + const { container } = render( + + ); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'without dismiss and icon', () => { + const { container } = render( + + ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'with no status and custom icon', () => { + const { container } = render( + } + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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 ( - +
); } ) } 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`] = `
- -
`; 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 ) => ( @@ -216,7 +216,7 @@ exports[`Onboarding: location check dialog renders correctly when opened 1`] = ` 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, : 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`] = `

Need cash in a hurry? Instant deposits are available within 30 minutes for a nominal 1.5% service fee. 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 tag inside the

Accept payments in multiple currencies. Prices are converted based on exchange rates and rounding rules. 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

Store settings allow your customers to choose which currency they would like to use when shopping at your store. 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 + ); + } } />

{ __( @@ -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`] = `

- 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)

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 ( 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 = () => ( + + { strings.steps.mode.devModeNotice } + +); 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 && } { return ( @@ -25,9 +24,14 @@ const PersonalDetails: React.FC = () => { - + { strings.steps.personal.notice } - + ); }; 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( ); 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 +
+ ), + }, + } ), }, 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: ( { __( '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. View details', + 'woocommerce-payments' + ), + { + // eslint-disable-next-line jsx-a11y/anchor-has-content + a: , + } + ); + 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. View details', + 'woocommerce-payments' + ), + { + // eslint-disable-next-line jsx-a11y/anchor-has-content + a: , + } + ); + 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 ( - { ); }, }, - ] } + ]; + + warningText = `${ title } ${ suffix }`; + } + } + + if ( ! showWarning && ! disableRefund ) { + return null; + } + + return ( + - - { title } { suffix } - - + { showWarning && { warningText } } + + { disableRefund &&
{ refundDisabledNotice }
} + ); }; 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:
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 + '%s You can challenge their claim if you believe it’s invalid. Not responding will result in an automatic loss. Learn more', + 'woocommerce-payments' + ) + : /* translators: 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 + '%s 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. Learn more about responding to disputes', + 'woocommerce-payments' + ); + + return ( + + { createInterpolateElement( sprintf( noticeText, clientClaim ), { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + strong: , + } ) } + + ); +}; + +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 && ( + } + buttonLabel={ __( + 'Learn more', + 'woocommerce-payments' + ) } + content={ + + } + /> + ) } + + ), + }, + { + title: __( 'Respond By', 'woocommerce-payments' ), + content: ( + + { respondByDate } + 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 + ) } + + + ), + }, + ]; + + return ( +
+ +
+ ); +}; + +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 ( + + ); +}; + +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 ( + + { file && file.id ? ( + + ) : ( + <> + ) } + + ); +}; + +interface Props { + issuerEvidence: IssuerEvidence | null; +} + +const IssuerEvidenceList: React.FC< Props > = ( { issuerEvidence } ) => { + if ( + ! issuerEvidence || + ! issuerEvidence.file_evidence.length || + ! issuerEvidence.text_evidence + ) { + return <>; + } + + return ( + +
    + { issuerEvidence.text_evidence && ( +
  • + +
  • + ) } + { issuerEvidence.file_evidence.map( + ( fileId: string, i: any ) => ( +
  • + +
  • + ) + ) } +
+
+ ); +}; + +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 (
- -
+ + { isAwaitingResponse( dispute.status ) && + countdownDays >= 0 && ( + <> + + { hasStagedEvidence && ( + + { __( + `You initiated a challenge to this dispute. Click 'Continue with challenge' to proceed with your draft response.`, + 'woocommerce-payments' + ) } + + ) } + + + + ) }
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( ); + + // 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( ); + + 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 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 @@ -573,7 +573,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders th > You must 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( ); - 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( ); - 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: , wooCommercePaymentsLink: ( // eslint-disable-next-line jsx-a11y/anchor-has-content - + ), 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 ( <> - { >

{ __( - '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' ) }

{ __( /* 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' ) }

@@ -106,12 +106,12 @@ const UpeSetupBanner = () => { - + { __( 'Learn more', 'woocommerce-payments' ) }

@@ -278,6 +278,30 @@ const PaymentMethods = () => { ) } + { isUpeEnabled && upeType === 'legacy' && ( + + + { 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 + + ), + }, + } ) } + + + ) } + { availableMethods.map( @@ -341,10 +365,21 @@ const PaymentMethods = () => { ) } - { isUpeSettingsPreviewEnabled && ! isUpeEnabled && ( - - ) } + + { isUpeSettingsPreviewEnabled && ! isUpeEnabled && ( + <> +
+ + + + + ) } + { activationModalParams && ( { 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 ( <> -

+

{ __( 'Debug mode', 'woocommerce-payments' ) }

{ - const [ isSectionExpanded, toggleIsSectionExpanded ] = useToggle( false ); - return ( <> - - - - { isSectionExpanded && ( - - - - - - { wcpaySettings.isClientEncryptionEligible && ( - - ) } - - - - - - - ) } + + + + { wcpaySettings.isClientEncryptionEligible && ( + + ) } + { wcpaySettings.isSubscriptionsActive && + wcpaySettings.isStripeBillingEligible ? ( + + ) : ( + + ) } + + + ); }; 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 - + ), }, } ) } 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 ( + + { 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 + + ), + }, + } ) } + + ); +}; + +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 ( + 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' + ) } + + ); +}; + +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 ( + + { 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 + + ), + }, + } ) } + + ); +}; + +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 ( + 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' + ) } + + ); +}; + +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 ( + <> + + { + context.startMigration(); + } } + isLoading={ context.isResolvingMigrateRequest } + hasResolved={ context.hasResolvedMigrateRequest } + /> + + + + ); +}; + +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 ( + +

{ __( 'Subscriptions', 'woocommerce-payments' ) }

+ + +
+ ); +}; + +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 ( + + ), + }, + } ) } + /> + ); +}; + +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( ); - - 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( ); - expect( screen.queryByText( 'Debug mode' ) ).not.toBeInTheDocument(); - - userEvent.click( screen.getByText( 'Advanced settings' ) ); + render( ); + // 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( ); - - expect( screen.queryByText( 'Debug mode' ) ).not.toBeInTheDocument(); - userEvent.click( screen.getByText( 'Advanced settings' ) ); + render( ); 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 ) ? ( { components: { learnMoreLink: ( // eslint-disable-next-line max-len - + ), }, } ) } 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 ( - + { interpolateComponents( { mixedString: __( 'Need help? Visit {{ docsLink /}} or {{supportLink /}}.', @@ -29,7 +29,7 @@ const NeedHelpBarSection = () => { components: { docsLink: ( // eslint-disable-next-line max-len - + { 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 = () => {
) } { ! isWooPayEnabled && ! isPaymentRequestEnabled && ( - + { __( 'To preview the express checkout buttons, ' + 'activate at least one express checkout.', @@ -161,7 +161,7 @@ const PaymentRequestButtonPreview = () => { ) } { isPaymentRequestEnabled && ! isLoading && ! paymentRequest && ( - + { __( '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 ? - : + : , termsLink: window.wcSettings?.storePages?.terms?.permalink ? - : + : , /* eslint-enable prettier/prettier */ + learnMoreLink: ( + // eslint-disable-next-line max-len + + ), } } ) } 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 => {
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 = () => { ) } >
- +
{
- { - /* eslint-disable jsx-a11y/anchor-has-content */ - interpolateComponents( { - mixedString: __( - '{{linkDocs}}Read more{{/linkDocs}}', - 'woocommerce-payments' - ), - components: { - linkDocs: ( - - ), - }, - } ) - /* eslint-enable jsx-a11y/anchor-has-content */ - } +
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 = () => { ) } >
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`]

@@ -76,7 +76,7 @@ exports[`CVC verification card renders correctly when CVC check is disabled 1`]
@@ -152,7 +152,7 @@ exports[`CVC verification card renders correctly when CVC check is enabled 1`] =

@@ -182,14 +182,14 @@ exports[`CVC verification card renders correctly when CVC check is enabled 1`] =
@@ -133,7 +133,7 @@ exports[`International IP address card renders correctly 1`] = `
@@ -267,7 +267,7 @@ exports[`International IP address card renders correctly when enabled 1`] = `

@@ -297,7 +297,7 @@ exports[`International IP address card renders correctly when enabled 1`] = `
@@ -431,7 +431,7 @@ exports[`International IP address card renders correctly when enabled and checke

@@ -461,7 +461,7 @@ exports[`International IP address card renders correctly when enabled and checke
@@ -594,7 +594,7 @@ exports[`International IP address card renders like disabled when checked, but n

@@ -624,7 +624,7 @@ exports[`International IP address card renders like disabled when checked, but n
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`] = `

@@ -293,7 +293,7 @@ exports[`Order items threshold card renders correctly when enabled 1`] = `
@@ -494,7 +494,7 @@ exports[`Order items threshold card renders correctly when enabled and checked 1

@@ -524,7 +524,7 @@ exports[`Order items threshold card renders correctly when enabled and checked 1
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`] = `

@@ -295,7 +295,7 @@ exports[`Purchase price threshold card renders correctly when enabled 1`] = `
@@ -498,7 +498,7 @@ exports[`Purchase price threshold card renders correctly when enabled and checke

@@ -528,7 +528,7 @@ exports[`Purchase price threshold card renders correctly when enabled and checke
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 ? : ; return ( -
@@ -61,7 +61,7 @@ Object {
@@ -77,7 +77,7 @@ Object { , "container":
@@ -107,7 +107,7 @@ Object {
@@ -205,7 +205,7 @@ Object {
@@ -234,7 +234,7 @@ Object {
@@ -253,7 +253,7 @@ Object { , "container":
@@ -282,7 +282,7 @@ Object {
@@ -383,7 +383,7 @@ Object {
@@ -412,7 +412,7 @@ Object {
@@ -431,7 +431,7 @@ Object { , "container":
@@ -460,7 +460,7 @@ Object {
@@ -561,7 +561,7 @@ Object {
@@ -590,7 +590,7 @@ Object {
@@ -609,7 +609,7 @@ Object { , "container":
@@ -638,7 +638,7 @@ Object {
@@ -739,7 +739,7 @@ Object {
@@ -768,7 +768,7 @@ Object {
@@ -787,7 +787,7 @@ Object { , "container":
@@ -816,7 +816,7 @@ Object {
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 {

@@ -332,7 +332,7 @@ Object {
@@ -726,7 +726,7 @@ Object {

@@ -756,7 +756,7 @@ Object {
@@ -942,7 +942,7 @@ Object {

@@ -972,14 +972,14 @@ Object {
For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. Learn more @@ -1300,7 +1300,7 @@ Object {

@@ -1330,7 +1330,7 @@ Object {
@@ -1724,7 +1724,7 @@ Object {

@@ -1754,7 +1754,7 @@ Object {
@@ -1940,7 +1940,7 @@ Object {

@@ -1970,14 +1970,14 @@ Object {
For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. Learn more @@ -2363,7 +2363,7 @@ Object {

@@ -2393,7 +2393,7 @@ Object {
@@ -2876,7 +2876,7 @@ Object {

@@ -2906,14 +2906,14 @@ Object {
@@ -3248,7 +3248,7 @@ Object {
@@ -3731,7 +3731,7 @@ Object {

@@ -3761,14 +3761,14 @@ Object {
@@ -4150,7 +4150,7 @@ Object {
@@ -4633,7 +4633,7 @@ Object {

@@ -4663,14 +4663,14 @@ Object {
@@ -4968,7 +4968,7 @@ Object {
@@ -5451,7 +5451,7 @@ Object {

@@ -5481,14 +5481,14 @@ Object {
@@ -5886,7 +5886,7 @@ Object {
@@ -6450,7 +6450,7 @@ Object {

@@ -6480,14 +6480,14 @@ Object {
@@ -6804,7 +6804,7 @@ Object {
@@ -7368,7 +7368,7 @@ Object {

@@ -7398,14 +7398,14 @@ Object {
For security, this filter is enabled and cannot be modified. Payments failing CVC verification will be blocked. 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 { />
@@ -61,7 +61,7 @@ Object {
@@ -77,7 +77,7 @@ Object { , "container":
@@ -107,7 +107,7 @@ Object {
@@ -205,7 +205,7 @@ Object {
@@ -234,7 +234,7 @@ Object {
@@ -250,7 +250,7 @@ Object { , "container":
@@ -279,7 +279,7 @@ Object {
@@ -377,7 +377,7 @@ Object {
@@ -407,7 +407,7 @@ Object {
@@ -423,7 +423,7 @@ Object { , "container":
@@ -453,7 +453,7 @@ Object {
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 && (
- There was an error retrieving your fraud protection settings. Please refresh the page to try again. +
+
+ + + + + +
+
+ There was an error retrieving your fraud protection settings. Please refresh the page to try again. +
+
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 = () => { ), }, 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' ) }

- + { __( 'Learn more', 'woocommerce-payments' ) } @@ -83,7 +83,7 @@ const TransactionsDescription = () => ( 'woocommerce-payments' ) }

- + { __( 'View our documentation', 'woocommerce-payments' ) } @@ -104,7 +104,7 @@ const DepositsDescription = () => { depositDelayDays ) }

- + { __( 'Learn more about pending schedules', 'woocommerce-payments' @@ -124,7 +124,7 @@ const FraudProtectionDescription = () => { 'woocommerce-payments' ) }

- + { __( 'Learn more about risk filtering', 'woocommerce-payments' @@ -134,6 +134,23 @@ const FraudProtectionDescription = () => { ); }; +const AdvancedDescription = () => { + return ( + <> +

{ __( 'Advanced settings', 'woocommerce-payments' ) }

+

+ { __( + 'More options for specific payment needs.', + 'woocommerce-payments' + ) } +

+ + { __( 'View our documentation', 'woocommerce-payments' ) } + + + ); +}; + const SettingsManager = () => { const { featureFlags: { @@ -252,7 +269,16 @@ const SettingsManager = () => { - + + + + + + + ); 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 && ( - + { __( "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 } ) => { ) } /> -

{ __( 'Customer support', 'woocommerce-payments' ) }

+

{ __( 'Customer statements', 'woocommerce-payments' ) }

+

+ { __( + "Edit the way your store name appears on your customers' bank statements.", + 'woocommerce-payments' + ) } +

-
+
{ customerBankStatementErrorMessage && ( { ) } { + { settings.account_country === 'JP' && ( + <> +
+ + +
+
+ + +
+ + ) } +
+ +

{ __( 'Customer support', 'woocommerce-payments' ) }

+

+ { __( + 'Provide contact information where customers can reach you for support.', + 'woocommerce-payments' + ) } +

+
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( ); + + 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 => ( - - { + // 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 ( + - - - -); + + + + + ); +}; interface ConvertedAmountProps { amount: number; @@ -62,11 +74,19 @@ const ConvertedAmount = ( { return <>{ formattedCurrency }; } + const isUpdatedTooltipAvailable = !! window?.wp?.components?.Tooltip; + return ( -
+
{ 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`] = `
; 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 > @@ -101,7 +101,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays base > @@ -158,7 +158,7 @@ exports[`Account fees utility functions formatMethodFeesTooltip() displays custo > 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\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\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\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 here).', 'woocommerce-payments' ), - [ 'a' => '' ] + [ 'a' => '' ] ), ], '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( __( 'Learn more.', 'woocommerce-payments' ), [ - 'a' => '', + 'a' => '', ] ); 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() { __( 'Test mode: use the test VISA card 4242424242424242 with any expiry date and CVC, or any test card numbers listed here.', 'woocommerce-payments' ), [ 'strong' => '', - 'a' => '', + 'a' => '', ] ); ?> 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' => '', - 'a' => '', + 'a' => '', ] ); $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' => '', - 'a' => '', + 'a' => '', ] ); } 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
'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 @@ +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 '

' . esc_html( $description ) . '

' . esc_html( $callout ) . ''; 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 = '
'; $widget_content .= $this->get_get_params(); - $widget_content .= '
'; - $widget_content .= ''; - 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 ''; + return ''; } /** 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' => '', ] ) ); 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' Instant Deposits feature, you're able to transfer your earnings to a debit card within minutes.", 'woocommerce-payments' ), 'WooPayments' ), - [ 'a' => '' ] + [ 'a' => '' ] ) ); $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' ), + '
', + $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' ), '', '', - '
', + '', '' ); ?> 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' ), - '', - '', + '', + '', '', '', '', 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[\d]+)@', '@^\/wc\/store(\/v[\d]+)?\/checkout$@', + '@^\/wc\/store(\/v[\d]+)?\/order\/(?P[\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 @@ CheckoutSchema + + + WC_Payments_Subscriptions::get_stripe_billing_migrator() + $stripe_billing_migrator + $stripe_billing_migrator + $stripe_billing_migrator + WC_Payments_Subscriptions::get_stripe_billing_migrator() + $stripe_billing_migrator + $stripe_billing_migrator + $stripe_billing_migrator + + 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 @@ +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 @@ +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 @@ + 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' => '

Risk filtering is only available for orders processed using credit cards with WooPayments.

Learn more', + 'expected_output' => '

Risk filtering is only available for orders processed using credit cards with WooPayments.

Learn more', ], 'simulate legacy UPE Bancontact' => [ 'payment_method_id' => 'woocommerce_payments', 'payment_method_title' => 'Bancontact', - 'expected_output' => '

Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.

Learn more', + 'expected_output' => '

Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.

Learn more', ], 'simulate split UPE Bancontact' => [ 'payment_method_id' => 'woocommerce_payments_bancontact', 'payment_method_title' => 'Bancontact', - 'expected_output' => '

Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.

Learn more', + 'expected_output' => '

Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Bancontact.

Learn more', ], ]; } @@ -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( '

Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Direct bank transfer.

Learn more' ); + $this->expectOutputString( '

Risk filtering is only available for orders processed using credit cards with WooPayments. This order was processed with Direct bank transfer.

Learn more' ); } 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 @@ +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( '', $result ); $this->assertStringContainsString( '', $result ); } + + public function test_render_currency_option_will_escape_output() { + $currency_code = '">'; + + // 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( ''; + $border_radius = '3">'; + $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( '