diff --git a/changelog.txt b/changelog.txt index 8a7175a7f..31b52698a 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,34 @@ *** Changelog *** += 8.9.0 - 2024-11-14 = +* Update - Enhance webhook processing to enable retrieving orders using payment_intent metadata. +* Dev - Minor updates to the webhook handler class related to payment method names constants. +* Tweak - Improve error message displayed when payment method creation fails in classic checkout. +* Dev - Replace two occurrences of payment method names with their constant equivalents. +* Fix - Hide express checkout when credit card payments are not enabled. +* Fix - Fix issues when detaching payment methods on staging sites (with the new checkout experience enabled). +* Fix - Display a notice if taxes vary by customer's billing address when checking out using the Stripe Express Checkout Element. +* Tweak - Makes the new Stripe Express Checkout Element enabled by default in new accounts. +* Dev - Add multiple unit tests for the Stripe Express Checkout Element implementation (for both frontend and backend). +* Fix - Check if taxes are enabled when applying ECE tax compatibility check. +* Fix - Fix ECE error when initial address on load is not defined as a shipping zone. +* Fix - Corrected card brand capitalization on the My Account → Subscription page. +* Fix - Displays a specific message when an authentication error occurs during checkout for 3DS cards (shortcode version). +* Fix - Show 'Use a New Payment Method' radio button for logged in users only when card saving is enabled. +* Fix - Fix the display and usage of the Link payment method on the shortcode checkout page with the Stripe Express Checkout Element. +* Fix - Fix payment methods count on settings page. +* Update - Improve Express Payment button previews on the edit Block Checkout and Cart pages for Google Pay and Apple Pay. +* Tweak - Add error logging in ECE critical Ajax requests. +* Add - Add support for Stripe Link payments via the new Stripe Checkout Element on the block cart and block checkout pages. +* Add - Add support for Stripe Link payments via the new Stripe Checkout Element on the product, cart, checkout and pay for order pages. +* Tweak - Do not load ECE button if the total amount is 0. +* Add - Show ECE button preview on settings page. +* Tweak - Remove the subscription order notes added each time a source wasn't migrated. +* Tweak - Update ECE default button type. +* Fix - Fix position of ECE button on shortcode cart page. +* Fix - Call ECE specific 'paymentFailed' function only when payment request fails. +* Fix - Fix issue in purchasing subscriptions when the store has no shipping options. + = 8.8.2 - 2024-11-07 = * Fix - Prevent marking renewal orders as processing/completed multiple times due to handling the Stripe webhook in parallel. * Dev - Refactor lock_order_payment() to use order meta instead of transients. diff --git a/client/blocks/express-checkout/apple-pay-preview.js b/client/blocks/express-checkout/apple-pay-preview.js deleted file mode 100644 index 932b3fa36..000000000 --- a/client/blocks/express-checkout/apple-pay-preview.js +++ /dev/null @@ -1,6 +0,0 @@ -const applePayImage = - "data:image/svg+xml,%3Csvg width='264' height='48' viewBox='0 0 264 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='264' height='48' rx='3' fill='black'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M125.114 16.6407C125.682 15.93 126.067 14.9756 125.966 14C125.135 14.0415 124.121 14.549 123.533 15.2602C123.006 15.8693 122.539 16.8641 122.661 17.7983C123.594 17.8797 124.526 17.3317 125.114 16.6407Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M125.955 17.982C124.601 17.9011 123.448 18.7518 122.801 18.7518C122.154 18.7518 121.163 18.0224 120.092 18.0421C118.696 18.0629 117.402 18.8524 116.694 20.1079C115.238 22.6196 116.31 26.3453 117.726 28.3909C118.414 29.4028 119.242 30.5174 120.334 30.4769C121.366 30.4365 121.77 29.8087 123.024 29.8087C124.277 29.8087 124.641 30.4769 125.733 30.4567C126.865 30.4365 127.573 29.4443 128.261 28.4313C129.049 27.2779 129.373 26.1639 129.393 26.1027C129.373 26.0825 127.209 25.2515 127.189 22.7606C127.169 20.6751 128.888 19.6834 128.969 19.6217C127.998 18.1847 126.481 18.0224 125.955 17.982Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M136.131 23.1804H138.834C140.886 23.1804 142.053 22.0752 142.053 20.1592C142.053 18.2432 140.886 17.1478 138.845 17.1478H136.131V23.1804ZM139.466 15.1582C142.411 15.1582 144.461 17.1903 144.461 20.1483C144.461 23.1172 142.369 25.1596 139.392 25.1596H136.131V30.3498H133.775V15.1582H139.466Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M152.198 26.224V25.3712L149.579 25.5397C148.106 25.6341 147.339 26.182 147.339 27.14C147.339 28.0664 148.138 28.6667 149.39 28.6667C150.988 28.6667 152.198 27.6449 152.198 26.224ZM145.046 27.2032C145.046 25.2551 146.529 24.1395 149.263 23.971L152.198 23.7922V22.9498C152.198 21.7181 151.388 21.0442 149.947 21.0442C148.758 21.0442 147.896 21.6548 147.717 22.5916H145.592C145.656 20.6232 147.507 19.1914 150.01 19.1914C152.703 19.1914 154.459 20.602 154.459 22.7917V30.351H152.282V28.5298H152.229C151.609 29.719 150.241 30.4666 148.758 30.4666C146.571 30.4666 145.046 29.1612 145.046 27.2032Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M156.461 34.4145V32.5934C156.608 32.6141 156.965 32.6354 157.155 32.6354C158.196 32.6354 158.785 32.1932 159.142 31.0564L159.353 30.3824L155.366 19.3281H157.827L160.604 28.298H160.657L163.434 19.3281H165.832L161.698 30.9402C160.752 33.6038 159.668 34.4778 157.376 34.4778C157.197 34.4778 156.618 34.4565 156.461 34.4145Z' fill='white'/%3E%3C/svg%3E%0A"; - -const ApplePayPreview = () => ; - -export default ApplePayPreview; diff --git a/client/blocks/express-checkout/express-button-previews/index.js b/client/blocks/express-checkout/express-button-previews/index.js new file mode 100644 index 000000000..be257b28d --- /dev/null +++ b/client/blocks/express-checkout/express-button-previews/index.js @@ -0,0 +1,60 @@ +import classNames from 'classnames'; +import googlePayIcon from '../../../payment-method-icons/google-pay/icon-white.svg'; +import applePayIcon from '../../../payment-method-icons/apple-pay/icon-white.svg'; +import stripeLinkIcon from '../../../payment-method-icons/link/icon-black.svg'; +import './style.scss'; + +/** + * Base PaymentButtonPreview Component + * + * @param {Object} props + * @param {string} props.icon - The icon to display. + * @param {string} [props.className] - Optional additional class names. + * @return {JSX.Element} The rendered component. + */ +const PaymentButtonPreview = ( { icon, className } ) => ( +
+ Payment Method Icon +
+); + +/** + * GooglePayPreview Component + * + * @return {JSX.Element} The rendered component. + */ +export const GooglePayPreview = () => ( + +); + +/** + * ApplePayPreview Component + * + * @return {JSX.Element} The rendered component. + */ +export const ApplePayPreview = () => ( + +); + +/** + * StripeLinkPreview Component + * + * @return {JSX.Element} The rendered component. + */ +export const StripeLinkPreview = () => ( + +); diff --git a/client/blocks/express-checkout/express-button-previews/style.scss b/client/blocks/express-checkout/express-button-previews/style.scss new file mode 100644 index 000000000..e87c22665 --- /dev/null +++ b/client/blocks/express-checkout/express-button-previews/style.scss @@ -0,0 +1,22 @@ +.wc-stripe-payment-button-preview { + display: flex; + justify-content: center; + align-items: center; + background-color: #000; + border-radius: 5px; + height: 40px; + img { + height: 22px; + } + &:hover { + cursor: pointer; + filter: opacity(0.7); + } + /* Stripe Link Overrides */ + &.wc-stripe-link-preview { + background-color: #00d66f; + img { + height: 40px; + } + } +} diff --git a/client/blocks/express-checkout/express-checkout-container.js b/client/blocks/express-checkout/express-checkout-container.js index c861948e7..0d564e022 100644 --- a/client/blocks/express-checkout/express-checkout-container.js +++ b/client/blocks/express-checkout/express-checkout-container.js @@ -1,14 +1,24 @@ import React from 'react'; import { Elements } from '@stripe/react-stripe-js'; import ExpressCheckoutComponent from './express-checkout-component'; +import { + getExpressCheckoutButtonAppearance, + getExpressCheckoutData, + getPaymentMethodTypesForExpressMethod, +} from 'wcstripe/express-checkout/utils'; export const ExpressCheckoutContainer = ( props ) => { - const { stripe, billing } = props; + const { stripe, billing, expressPaymentMethod } = props; const options = { mode: 'payment', paymentMethodCreation: 'manual', amount: billing.cartTotal.value, currency: billing.currency.code.toLowerCase(), + paymentMethodTypes: getPaymentMethodTypesForExpressMethod( + expressPaymentMethod + ), + appearance: getExpressCheckoutButtonAppearance(), + locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en', }; return ( diff --git a/client/blocks/express-checkout/google-pay-preview.js b/client/blocks/express-checkout/google-pay-preview.js deleted file mode 100644 index fc110d670..000000000 --- a/client/blocks/express-checkout/google-pay-preview.js +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable max-len */ -const googlePayImage = - "data:image/svg+xml,%3Csvg width='343' height='50' viewBox='0 0 343 50' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='343' height='50' rx='8' fill='black'/%3E%3Cpath d='M94.9511 24.6726V31.7486H92.6902V14.2749H98.6838C100.203 14.2749 101.498 14.7778 102.558 15.7837C103.641 16.7895 104.183 18.0176 104.183 19.4679C104.183 20.9533 103.641 22.1813 102.558 23.1755C101.51 24.1696 100.215 24.6609 98.6838 24.6609H94.9511V24.6726ZM94.9511 16.4269V22.5205H98.7309C99.6258 22.5205 100.379 22.2164 100.968 21.6199C101.569 21.0234 101.875 20.2983 101.875 19.4796C101.875 18.6726 101.569 17.9591 100.968 17.3626C100.379 16.7427 99.6375 16.4386 98.7309 16.4386H94.9511V16.4269Z' fill='white'/%3E%3Cpath d='M110.094 19.3977C111.766 19.3977 113.085 19.8421 114.05 20.731C115.016 21.6199 115.499 22.8363 115.499 24.3802V31.7486H113.344V30.0878H113.25C112.319 31.4562 111.071 32.1345 109.517 32.1345C108.186 32.1345 107.079 31.7486 106.185 30.9649C105.29 30.1813 104.842 29.2106 104.842 28.041C104.842 26.8012 105.313 25.8188 106.255 25.0936C107.197 24.3568 108.457 23.9942 110.023 23.9942C111.366 23.9942 112.472 24.2398 113.332 24.731V24.2164C113.332 23.4328 113.026 22.7778 112.402 22.2281C111.778 21.6784 111.048 21.4094 110.212 21.4094C108.952 21.4094 107.951 21.9357 107.221 23L105.231 21.7603C106.326 20.1813 107.951 19.3977 110.094 19.3977ZM107.174 28.0761C107.174 28.6609 107.421 29.1521 107.927 29.538C108.422 29.924 109.011 30.1228 109.682 30.1228C110.636 30.1228 111.483 29.772 112.225 29.0702C112.967 28.3685 113.344 27.5497 113.344 26.6024C112.637 26.0527 111.66 25.772 110.4 25.772C109.482 25.772 108.716 25.9942 108.104 26.4269C107.48 26.8831 107.174 27.4328 107.174 28.0761Z' fill='white'/%3E%3Cpath d='M127.792 19.7837L120.256 37.0001H117.924L120.727 30.9767L115.758 19.7837H118.219L121.798 28.3685H121.845L125.331 19.7837H127.792Z' fill='white'/%3E%3Cpath d='M85.9586 23.2456C85.9586 22.5134 85.8926 21.8128 85.7702 21.1392H76.2936V24.9988L81.7513 25C81.53 26.2842 80.8176 27.3789 79.726 28.1087V30.6128H82.9748C84.8717 28.869 85.9586 26.2912 85.9586 23.2456Z' fill='%234285F4'/%3E%3Cpath d='M79.7272 28.1088C78.8229 28.7146 77.6583 29.069 76.2959 29.069C73.6642 29.069 71.4317 27.3076 70.6321 24.9333H67.2809V27.5158C68.9412 30.7883 72.3536 33.0339 76.2959 33.0339C79.0207 33.0339 81.3098 32.1439 82.9759 30.6117L79.7272 28.1088Z' fill='%2334A853'/%3E%3Cpath d='M70.3165 23.0176C70.3165 22.3509 70.4284 21.7065 70.6321 21.1006V18.5182H67.2809C66.5944 19.8714 66.2082 21.3989 66.2082 23.0176C66.2082 24.6363 66.5956 26.1638 67.2809 27.517L70.6321 24.9346C70.4284 24.3287 70.3165 23.6843 70.3165 23.0176Z' fill='%23FABB05'/%3E%3Cpath d='M76.2959 16.9649C77.7831 16.9649 79.1149 17.4737 80.1664 18.4678L83.0454 15.6105C81.2968 13.993 79.0172 13 76.2959 13C72.3548 13 68.9412 15.2456 67.2809 18.5181L70.6321 21.1006C71.4317 18.7263 73.6642 16.9649 76.2959 16.9649Z' fill='%23E94235'/%3E%3Cline x1='140.792' y1='12' x2='140.792' y2='38' stroke='%235F6368' stroke-width='2'/%3E%3Crect x='151.292' y='11.962' width='41' height='26' stroke='white'/%3E%3Cpath d='M187.766 23.7321C187.733 22.9139 187.369 22.1408 186.752 21.5792C186.135 21.0176 185.315 20.7123 184.467 20.7289C183.454 20.6861 182.434 20.7289 181.421 20.6984C181.149 20.6984 181.054 20.7779 181.01 21.0409C180.661 23.255 180.288 25.4631 179.92 27.6772C179.832 28.2154 179.749 28.7537 179.661 29.3042C179.721 29.3178 179.783 29.328 179.844 29.3347C180.87 29.3347 181.896 29.3347 182.916 29.3347C183.738 29.3504 184.55 29.1576 185.271 28.7756C185.992 28.3935 186.597 27.8355 187.025 27.1573C187.657 26.1261 187.918 24.9216 187.766 23.7321ZM184.98 26.405C184.783 26.8111 184.465 27.1511 184.067 27.3811C183.669 27.6111 183.208 27.7206 182.745 27.6955H182.111C182.219 27.0289 182.314 26.3989 182.422 25.7689C182.593 24.723 182.776 23.6709 182.941 22.6189C182.941 22.4171 183.036 22.3314 183.251 22.3498C183.365 22.359 183.479 22.359 183.593 22.3498C183.815 22.3177 184.042 22.3315 184.258 22.3903C184.475 22.4491 184.676 22.5515 184.848 22.6908C185.02 22.8301 185.16 23.0031 185.258 23.1984C185.356 23.3937 185.41 23.6069 185.417 23.8239C185.573 24.7059 185.424 25.6131 184.993 26.405' fill='white'/%3E%3Cpath d='M178.268 21.1081C177.831 20.9036 177.358 20.7831 176.874 20.7534C175.855 20.6922 174.829 20.7534 173.803 20.7106C173.537 20.7106 173.474 20.8084 173.442 21.0286C173.084 23.255 172.718 25.4793 172.347 27.7016C172.258 28.2338 172.176 28.772 172.081 29.3469C172.714 29.3469 173.347 29.3469 173.981 29.3469C174.215 29.3469 174.297 29.2674 174.329 29.0472C174.449 28.2338 174.601 27.4203 174.721 26.6007C174.721 26.405 174.829 26.3193 175.038 26.3438C175.12 26.3561 175.203 26.3561 175.285 26.3438C175.507 26.3438 175.595 26.3988 175.633 26.6129C175.722 27.1145 175.83 27.6099 175.95 28.1053C175.998 28.4255 176.15 28.7226 176.385 28.9535C176.619 29.1844 176.924 29.3372 177.254 29.3898C177.767 29.4233 178.282 29.411 178.793 29.3531C178.829 29.3432 178.861 29.3245 178.887 29.2987C178.912 29.2729 178.93 29.241 178.939 29.2063C178.977 28.772 178.996 28.3377 179.021 27.8912H178.704C178.598 27.9031 178.491 27.8944 178.388 27.8656C178.285 27.8368 178.19 27.7886 178.107 27.7236C178.023 27.6587 177.955 27.5784 177.904 27.4875C177.853 27.3965 177.822 27.2968 177.812 27.194C177.717 26.8453 177.66 26.4906 177.546 26.1481C177.476 25.9279 177.546 25.83 177.736 25.7199C178.164 25.4948 178.532 25.1773 178.812 24.7922C179.092 24.4071 179.276 23.9648 179.35 23.4997C179.521 22.4232 179.16 21.5241 178.293 21.1265L178.268 21.1081ZM176.817 24.2826C176.745 24.4438 176.621 24.5784 176.464 24.6665C176.307 24.7546 176.124 24.7915 175.944 24.7719C175.64 24.7564 175.336 24.7564 175.032 24.7719C175.158 24.0073 175.266 23.3039 175.393 22.6005C175.408 22.5626 175.431 22.5285 175.462 22.501C175.493 22.4734 175.53 22.453 175.57 22.4415C175.827 22.4171 176.086 22.4171 176.343 22.4415C176.529 22.4659 176.7 22.5528 176.826 22.687C176.952 22.8213 177.026 22.9943 177.033 23.1755C177.084 23.5543 176.999 23.9388 176.792 24.2642' fill='white'/%3E%3Cpath d='M171.061 27.2062C170.625 25.1266 170.188 23.0532 169.757 20.9675C169.713 20.7779 169.637 20.7106 169.428 20.7106C168.953 20.7106 168.478 20.7106 168.003 20.7106C167.907 20.6989 167.811 20.7184 167.728 20.7661C167.645 20.8138 167.582 20.8868 167.547 20.9736C166.724 22.5088 165.894 24.0318 165.071 25.567C165.027 25.6588 164.976 25.7444 164.925 25.8361L163.216 29.0106C163.165 29.1023 163.127 29.2002 163.057 29.347C163.811 29.347 164.514 29.347 165.21 29.347C165.286 29.347 165.381 29.243 165.419 29.1635C165.66 28.7047 165.894 28.2399 166.116 27.7628C166.143 27.6883 166.195 27.6247 166.264 27.5825C166.333 27.5402 166.415 27.5219 166.496 27.5304C167.205 27.5304 167.921 27.5304 168.636 27.5304C168.851 27.5304 168.921 27.6038 168.953 27.7934C169.016 28.2277 169.086 28.6619 169.174 29.0901C169.174 29.1879 169.288 29.347 169.358 29.347C170.055 29.347 170.757 29.347 171.498 29.347C171.498 29.194 171.46 29.0778 171.435 28.9616C171.315 28.35 171.188 27.7934 171.061 27.2062ZM167.047 25.8973C167.427 25.1266 167.794 24.3927 168.161 23.6587H168.25C168.37 24.3927 168.497 25.1266 168.624 25.8973H167.047Z' fill='white'/%3E%3Cpath d='M161.101 27.6712C160.883 27.7345 160.653 27.7492 160.428 27.7139C160.203 27.6787 159.99 27.5946 159.804 27.4679C159.618 27.3412 159.464 27.1752 159.354 26.9826C159.244 26.79 159.181 26.5758 159.169 26.3561C159.021 25.4427 159.16 24.5072 159.568 23.671C159.728 23.2847 160.004 22.9537 160.361 22.7213C160.718 22.4888 161.139 22.3658 161.569 22.3682H161.835C162.304 22.4049 162.773 22.4844 163.273 22.5517C163.45 22.0502 163.64 21.5303 163.83 21.0043C163.694 20.8051 163.504 20.6456 163.281 20.5432C163.057 20.4408 162.809 20.3993 162.564 20.4232H157.65L157.599 20.6495C158.2 20.8013 158.79 20.9913 159.366 21.2183C158.824 21.5117 158.349 21.9093 157.973 22.3866C157.03 23.6171 156.604 25.1469 156.782 26.6681C156.804 27.2214 156.996 27.7558 157.335 28.2026C157.673 28.6495 158.142 28.9884 158.682 29.1758C159.322 29.4124 160.008 29.5146 160.692 29.4756C161.377 29.4365 162.045 29.2572 162.652 28.9495C162.722 28.9128 162.817 28.821 162.811 28.7599C162.754 28.2706 162.678 27.7874 162.614 27.3286C162.089 27.451 161.614 27.5855 161.126 27.6712' fill='white'/%3E%3Cpath d='M203.482 28.956C203.17 28.956 202.876 28.896 202.6 28.776C202.336 28.656 202.102 28.494 201.898 28.29C201.694 28.086 201.532 27.852 201.412 27.588C201.292 27.312 201.232 27.018 201.232 26.706C201.232 26.394 201.292 26.106 201.412 25.842C201.532 25.566 201.694 25.326 201.898 25.122C202.102 24.918 202.336 24.756 202.6 24.636C202.876 24.516 203.17 24.456 203.482 24.456C203.794 24.456 204.088 24.516 204.364 24.636C204.64 24.756 204.88 24.918 205.084 25.122C205.288 25.326 205.45 25.566 205.57 25.842C205.69 26.106 205.75 26.394 205.75 26.706C205.75 27.018 205.69 27.312 205.57 27.588C205.45 27.852 205.288 28.086 205.084 28.29C204.88 28.494 204.64 28.656 204.364 28.776C204.088 28.896 203.794 28.956 203.482 28.956ZM210.882 28.956C210.57 28.956 210.276 28.896 210 28.776C209.736 28.656 209.502 28.494 209.298 28.29C209.094 28.086 208.932 27.852 208.812 27.588C208.692 27.312 208.632 27.018 208.632 26.706C208.632 26.394 208.692 26.106 208.812 25.842C208.932 25.566 209.094 25.326 209.298 25.122C209.502 24.918 209.736 24.756 210 24.636C210.276 24.516 210.57 24.456 210.882 24.456C211.194 24.456 211.488 24.516 211.764 24.636C212.04 24.756 212.28 24.918 212.484 25.122C212.688 25.326 212.85 25.566 212.97 25.842C213.09 26.106 213.15 26.394 213.15 26.706C213.15 27.018 213.09 27.312 212.97 27.588C212.85 27.852 212.688 28.086 212.484 28.29C212.28 28.494 212.04 28.656 211.764 28.776C211.488 28.896 211.194 28.956 210.882 28.956ZM218.283 28.956C217.971 28.956 217.677 28.896 217.401 28.776C217.137 28.656 216.903 28.494 216.699 28.29C216.495 28.086 216.333 27.852 216.213 27.588C216.093 27.312 216.033 27.018 216.033 26.706C216.033 26.394 216.093 26.106 216.213 25.842C216.333 25.566 216.495 25.326 216.699 25.122C216.903 24.918 217.137 24.756 217.401 24.636C217.677 24.516 217.971 24.456 218.283 24.456C218.595 24.456 218.889 24.516 219.165 24.636C219.441 24.756 219.681 24.918 219.885 25.122C220.089 25.326 220.251 25.566 220.371 25.842C220.491 26.106 220.551 26.394 220.551 26.706C220.551 27.018 220.491 27.312 220.371 27.588C220.251 27.852 220.089 28.086 219.885 28.29C219.681 28.494 219.441 28.656 219.165 28.776C218.889 28.896 218.595 28.956 218.283 28.956ZM225.683 28.956C225.371 28.956 225.077 28.896 224.801 28.776C224.537 28.656 224.303 28.494 224.099 28.29C223.895 28.086 223.733 27.852 223.613 27.588C223.493 27.312 223.433 27.018 223.433 26.706C223.433 26.394 223.493 26.106 223.613 25.842C223.733 25.566 223.895 25.326 224.099 25.122C224.303 24.918 224.537 24.756 224.801 24.636C225.077 24.516 225.371 24.456 225.683 24.456C225.995 24.456 226.289 24.516 226.565 24.636C226.841 24.756 227.081 24.918 227.285 25.122C227.489 25.326 227.651 25.566 227.771 25.842C227.891 26.106 227.951 26.394 227.951 26.706C227.951 27.018 227.891 27.312 227.771 27.588C227.651 27.852 227.489 28.086 227.285 28.29C227.081 28.494 226.841 28.656 226.565 28.776C226.289 28.896 225.995 28.956 225.683 28.956ZM235.566 31.908L240.984 22.188L240.948 22.116H235.008L235.044 20.226H243.18V22.224L237.294 32.826L235.566 31.908ZM249.263 32.826C248.831 32.826 248.399 32.766 247.967 32.646C247.535 32.526 247.127 32.34 246.743 32.088C246.371 31.824 246.035 31.494 245.735 31.098C245.435 30.702 245.207 30.228 245.051 29.676L246.923 28.902C247.091 29.55 247.379 30.048 247.787 30.396C248.195 30.744 248.687 30.918 249.263 30.918C249.551 30.918 249.827 30.87 250.091 30.774C250.367 30.678 250.607 30.546 250.811 30.378C251.015 30.198 251.177 29.994 251.297 29.766C251.417 29.526 251.477 29.262 251.477 28.974C251.477 28.686 251.411 28.422 251.279 28.182C251.159 27.942 250.991 27.738 250.775 27.57C250.559 27.402 250.301 27.27 250.001 27.174C249.713 27.078 249.401 27.03 249.065 27.03H248.003V25.176H248.957C249.245 25.176 249.509 25.14 249.749 25.068C250.001 24.984 250.223 24.87 250.415 24.726C250.607 24.582 250.757 24.402 250.865 24.186C250.973 23.958 251.027 23.7 251.027 23.412C251.027 22.92 250.847 22.536 250.487 22.26C250.139 21.972 249.695 21.828 249.155 21.828C248.855 21.828 248.591 21.87 248.363 21.954C248.147 22.038 247.961 22.152 247.805 22.296C247.649 22.44 247.517 22.602 247.409 22.782C247.313 22.95 247.235 23.124 247.175 23.304L245.339 22.53C245.435 22.242 245.585 21.948 245.789 21.648C245.993 21.336 246.251 21.054 246.563 20.802C246.875 20.55 247.241 20.346 247.661 20.19C248.093 20.022 248.591 19.938 249.155 19.938C249.731 19.938 250.259 20.022 250.739 20.19C251.219 20.358 251.633 20.592 251.981 20.892C252.329 21.192 252.599 21.546 252.791 21.954C252.983 22.362 253.079 22.806 253.079 23.286C253.079 23.634 253.031 23.952 252.935 24.24C252.851 24.528 252.731 24.786 252.575 25.014C252.419 25.23 252.245 25.416 252.053 25.572C251.861 25.728 251.663 25.86 251.459 25.968V26.076C251.735 26.196 251.999 26.352 252.251 26.544C252.503 26.724 252.719 26.94 252.899 27.192C253.091 27.444 253.241 27.732 253.349 28.056C253.457 28.368 253.511 28.71 253.511 29.082C253.511 29.622 253.403 30.126 253.187 30.594C252.971 31.05 252.671 31.446 252.287 31.782C251.915 32.106 251.471 32.358 250.955 32.538C250.439 32.73 249.875 32.826 249.263 32.826ZM261.209 32.538V30.108H255.449V28.524L261.047 20.226H263.225V28.236H264.773V30.108H263.225V32.538H261.209ZM261.209 23.196H261.101L257.699 28.236H261.209V23.196ZM271.097 32.826C270.473 32.826 269.897 32.73 269.369 32.538C268.841 32.346 268.385 32.088 268.001 31.764C267.617 31.428 267.317 31.038 267.101 30.594C266.885 30.138 266.777 29.646 266.777 29.118C266.777 28.758 266.825 28.428 266.921 28.128C267.029 27.816 267.173 27.534 267.353 27.282C267.533 27.03 267.737 26.808 267.965 26.616C268.205 26.412 268.457 26.244 268.721 26.112V26.004C268.301 25.728 267.941 25.368 267.641 24.924C267.341 24.48 267.191 23.976 267.191 23.412C267.191 22.92 267.287 22.464 267.479 22.044C267.671 21.612 267.941 21.24 268.289 20.928C268.637 20.616 269.051 20.376 269.531 20.208C270.011 20.028 270.533 19.938 271.097 19.938C271.661 19.938 272.183 20.028 272.663 20.208C273.143 20.376 273.557 20.616 273.905 20.928C274.253 21.24 274.523 21.612 274.715 22.044C274.907 22.464 275.003 22.92 275.003 23.412C275.003 23.976 274.853 24.48 274.553 24.924C274.253 25.368 273.893 25.728 273.473 26.004V26.112C273.737 26.244 273.983 26.412 274.211 26.616C274.451 26.808 274.661 27.03 274.841 27.282C275.021 27.534 275.159 27.816 275.255 28.128C275.363 28.428 275.417 28.758 275.417 29.118C275.417 29.646 275.309 30.138 275.093 30.594C274.877 31.038 274.577 31.428 274.193 31.764C273.809 32.088 273.353 32.346 272.825 32.538C272.297 32.73 271.721 32.826 271.097 32.826ZM271.097 25.212C271.637 25.212 272.087 25.062 272.447 24.762C272.819 24.45 273.005 24.03 273.005 23.502C273.005 22.962 272.819 22.542 272.447 22.242C272.087 21.942 271.637 21.792 271.097 21.792C270.557 21.792 270.101 21.942 269.729 22.242C269.369 22.542 269.189 22.962 269.189 23.502C269.189 24.03 269.369 24.45 269.729 24.762C270.101 25.062 270.557 25.212 271.097 25.212ZM271.097 30.936C271.421 30.936 271.721 30.888 271.997 30.792C272.273 30.696 272.513 30.564 272.717 30.396C272.933 30.228 273.095 30.024 273.203 29.784C273.323 29.532 273.383 29.262 273.383 28.974C273.383 28.686 273.323 28.422 273.203 28.182C273.083 27.93 272.921 27.72 272.717 27.552C272.513 27.384 272.273 27.252 271.997 27.156C271.721 27.06 271.421 27.012 271.097 27.012C270.773 27.012 270.473 27.06 270.197 27.156C269.921 27.252 269.681 27.384 269.477 27.552C269.273 27.72 269.111 27.93 268.991 28.182C268.871 28.422 268.811 28.686 268.811 28.974C268.811 29.262 268.865 29.532 268.973 29.784C269.093 30.024 269.255 30.228 269.459 30.396C269.675 30.564 269.921 30.696 270.197 30.792C270.473 30.888 270.773 30.936 271.097 30.936Z' fill='white'/%3E%3C/svg%3E%0A"; - -const GooglePayPreview = () => ; - -export default GooglePayPreview; diff --git a/client/blocks/express-checkout/hooks.js b/client/blocks/express-checkout/hooks.js index 55023c721..30e78550f 100644 --- a/client/blocks/express-checkout/hooks.js +++ b/client/blocks/express-checkout/hooks.js @@ -1,4 +1,5 @@ import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import { useStripe, useElements } from '@stripe/react-stripe-js'; import { onAbortPaymentHandler, @@ -8,6 +9,8 @@ import { onConfirmHandler, } from 'wcstripe/express-checkout/event-handler'; import { + displayExpressCheckoutNotice, + expressCheckoutNoticeDelay, getExpressCheckoutButtonStyleSettings, getExpressCheckoutData, normalizeLineItems, @@ -36,14 +39,42 @@ export const useExpressCheckout = ( { window.location = redirectUrl; }; - const abortPayment = ( onConfirmEvent, message ) => { - onConfirmEvent.paymentFailed( { reason: 'fail' } ); + const abortPayment = ( onConfirmEvent, message, isOrderError = false ) => { + if ( ! isOrderError ) { + onConfirmEvent.paymentFailed( { reason: 'fail' } ); + } setExpressPaymentError( message ); onAbortPaymentHandler( onConfirmEvent, message ); }; const onButtonClick = useCallback( - ( event ) => { + async ( event ) => { + const getShippingRates = () => { + // shippingData.shippingRates[ 0 ].shipping_rates will be non-empty + // only when the express checkout element's default shipping address + // has a shipping method defined in WooCommerce. + if ( + shippingData?.shippingRates[ 0 ]?.shipping_rates?.length > 0 + ) { + return shippingData.shippingRates[ 0 ].shipping_rates.map( + ( r ) => { + return { + id: r.rate_id, + amount: parseInt( r.price, 10 ), + displayName: r.name, + }; + } + ); + } + + // Return a default shipping option, as a non-empty shippingRates array + // is required when shippingAddressRequired is true. + const defaultShippingOption = getExpressCheckoutData( + 'checkout' + )?.default_shipping_option; + return defaultShippingOption ? [ defaultShippingOption ] : []; + }; + const options = { lineItems: normalizeLineItems( billing?.cartTotalItems ), emailRequired: true, @@ -51,19 +82,27 @@ export const useExpressCheckout = ( { phoneNumberRequired: getExpressCheckoutData( 'checkout' )?.needs_payer_phone ?? false, - shippingRates: shippingData?.shippingRates[ 0 ]?.shipping_rates?.map( - ( r ) => { - return { - id: r.rate_id, - amount: parseInt( r.price, 10 ), - displayName: r.name, - }; - } - ), + ...( shippingData?.needsShipping && { + shippingRates: getShippingRates(), + } ), }; // Click event from WC Blocks. onClick(); + + if ( getExpressCheckoutData( 'taxes_based_on_billing' ) ) { + displayExpressCheckoutNotice( + __( + 'Final taxes charged can differ based on your actual billing address when using Express Checkout buttons (Link, Google Pay or Apple Pay).', + 'woocommerce-gateway-stripe' + ), + 'info', + [ 'ece-taxes-info' ] + ); + // Wait for the notice to be displayed before proceeding. + await expressCheckoutNoticeDelay(); + } + // Global click event handler to ECE. onClickHandler( event ); event.resolve( options ); diff --git a/client/blocks/express-checkout/index.js b/client/blocks/express-checkout/index.js index 07a34dedd..3f3607bed 100644 --- a/client/blocks/express-checkout/index.js +++ b/client/blocks/express-checkout/index.js @@ -2,8 +2,11 @@ import { PAYMENT_METHOD_EXPRESS_CHECKOUT_ELEMENT } from './constants'; import { ExpressCheckoutContainer } from './express-checkout-container'; -import ApplePayPreview from './apple-pay-preview'; -import GooglePayPreview from './google-pay-preview'; +import { + ApplePayPreview, + GooglePayPreview, + StripeLinkPreview, +} from './express-button-previews'; import { loadStripe } from 'wcstripe/blocks/load-stripe'; import { getBlocksConfiguration } from 'wcstripe/blocks/utils'; import { checkPaymentMethodIsAvailable } from 'wcstripe/express-checkout/utils/check-payment-method-availability'; @@ -12,6 +15,7 @@ const stripePromise = loadStripe(); const expressCheckoutElementsGooglePay = ( api ) => ( { name: PAYMENT_METHOD_EXPRESS_CHECKOUT_ELEMENT + '_googlePay', + title: 'WooCommerce Stripe - Google Pay', content: ( ( { ), edit: , canMakePayment: ( { cart } ) => { + if ( ! getBlocksConfiguration()?.shouldShowExpressCheckoutButton ) { + return false; + } + // eslint-disable-next-line camelcase if ( typeof wc_stripe_express_checkout_params === 'undefined' ) { return false; @@ -31,6 +39,7 @@ const expressCheckoutElementsGooglePay = ( api ) => ( { } ); }, paymentMethodId: PAYMENT_METHOD_EXPRESS_CHECKOUT_ELEMENT, + gatewayId: 'stripe', supports: { features: getBlocksConfiguration()?.supports ?? [], }, @@ -38,6 +47,7 @@ const expressCheckoutElementsGooglePay = ( api ) => ( { const expressCheckoutElementsApplePay = ( api ) => ( { name: PAYMENT_METHOD_EXPRESS_CHECKOUT_ELEMENT + '_applePay', + title: 'WooCommerce Stripe - Apple Pay', content: ( ( { ), edit: , canMakePayment: ( { cart } ) => { + if ( ! getBlocksConfiguration()?.shouldShowExpressCheckoutButton ) { + return false; + } + // eslint-disable-next-line camelcase if ( typeof wc_stripe_express_checkout_params === 'undefined' ) { return false; @@ -57,9 +71,44 @@ const expressCheckoutElementsApplePay = ( api ) => ( { } ); }, paymentMethodId: PAYMENT_METHOD_EXPRESS_CHECKOUT_ELEMENT, + gatewayId: 'stripe', + supports: { + features: getBlocksConfiguration()?.supports ?? [], + }, +} ); + +const expressCheckoutElementsStripeLink = ( api ) => ( { + name: PAYMENT_METHOD_EXPRESS_CHECKOUT_ELEMENT + '_link', + content: ( + + ), + edit: , + canMakePayment: ( { cart } ) => { + if ( ! getBlocksConfiguration()?.shouldShowExpressCheckoutButton ) { + return false; + } + + // eslint-disable-next-line camelcase + if ( typeof wc_stripe_express_checkout_params === 'undefined' ) { + return false; + } + + return new Promise( ( resolve ) => { + checkPaymentMethodIsAvailable( 'link', api, cart, resolve ); + } ); + }, + paymentMethodId: PAYMENT_METHOD_EXPRESS_CHECKOUT_ELEMENT, supports: { features: getBlocksConfiguration()?.supports ?? [], }, } ); -export { expressCheckoutElementsGooglePay, expressCheckoutElementsApplePay }; +export { + expressCheckoutElementsGooglePay, + expressCheckoutElementsApplePay, + expressCheckoutElementsStripeLink, +}; diff --git a/client/blocks/upe/index.js b/client/blocks/upe/index.js index 7afa5306d..189fed2ca 100644 --- a/client/blocks/upe/index.js +++ b/client/blocks/upe/index.js @@ -11,6 +11,7 @@ import paymentRequestPaymentMethod from 'wcstripe/blocks/payment-request'; import { expressCheckoutElementsGooglePay, expressCheckoutElementsApplePay, + expressCheckoutElementsStripeLink, } from 'wcstripe/blocks/express-checkout'; import WCStripeAPI from 'wcstripe/api'; import { getBlocksConfiguration } from 'wcstripe/blocks/utils'; @@ -97,6 +98,7 @@ if ( getBlocksConfiguration()?.isECEEnabled ) { // Register Express Checkout Element. registerExpressPaymentMethod( expressCheckoutElementsGooglePay( api ) ); registerExpressPaymentMethod( expressCheckoutElementsApplePay( api ) ); + registerExpressPaymentMethod( expressCheckoutElementsStripeLink( api ) ); } else { // Register Stripe Payment Request. registerExpressPaymentMethod( paymentRequestPaymentMethod ); diff --git a/client/blocks/utils.js b/client/blocks/utils.js index 23512ce08..79fbd9e73 100644 --- a/client/blocks/utils.js +++ b/client/blocks/utils.js @@ -1,7 +1,7 @@ -import { getSetting } from '@woocommerce/settings'; +/* global wc */ export const getBlocksConfiguration = () => { - const stripeServerData = getSetting( 'stripe_data', null ); + const stripeServerData = wc?.wcSettings?.getSetting( 'stripe_data', null ); if ( ! stripeServerData ) { throw new Error( 'Stripe initialization data is not available' ); diff --git a/client/data/constants.js b/client/data/constants.js index 4eb9f4350..6f51eac9e 100644 --- a/client/data/constants.js +++ b/client/data/constants.js @@ -1,2 +1,20 @@ export const NAMESPACE = '/wc/v3/wc_stripe'; export const STORE_NAME = 'wc/stripe'; + +/** + * The amount threshold for displaying the notice. + * + * @type {number} The threshold amount. + */ +export const CASH_APP_NOTICE_AMOUNT_THRESHOLD = 200000; + +/** + * Wait time in ms for a notice to be displayed in ECE before proceeding with the checkout process. + * + * Reasons for this value: + * - We cannot display an alert message because it blocks the default ECE process + * - The delay cannot be higher than 1s due to Stripe JS limitations (it times out after 1s) + * + * @type {number} The delay in milliseconds. + */ +export const EXPRESS_CHECKOUT_NOTICE_DELAY = 700; diff --git a/client/entrypoints/express-checkout/index.js b/client/entrypoints/express-checkout/index.js index 0fd7ec117..27a5d9e69 100644 --- a/client/entrypoints/express-checkout/index.js +++ b/client/entrypoints/express-checkout/index.js @@ -4,10 +4,13 @@ import { debounce } from 'lodash'; import jQuery from 'jquery'; import WCStripeAPI from '../../api'; import { + displayExpressCheckoutNotice, displayLoginConfirmation, + expressCheckoutNoticeDelay, getExpressCheckoutButtonAppearance, getExpressCheckoutButtonStyleSettings, getExpressCheckoutData, + getExpressPaymentMethodTypes, normalizeLineItems, } from 'wcstripe/express-checkout/utils'; import { @@ -96,18 +99,7 @@ jQuery( function ( $ ) { } if ( getExpressCheckoutData( 'is_product_page' ) ) { - // Despite the name of the property, this seems to be just a single option that's not in an array. - const { - shippingOptions: shippingOption, - } = getExpressCheckoutData( 'product' ); - - return [ - { - id: shippingOption.id, - amount: shippingOption.amount, - displayName: shippingOption.label, - }, - ]; + return getExpressCheckoutData( 'product' )?.shippingOptions; } return options.displayItems @@ -140,6 +132,8 @@ jQuery( function ( $ ) { currency: options.currency, paymentMethodCreation: 'manual', appearance: getExpressCheckoutButtonAppearance(), + locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en', + paymentMethodTypes: getExpressPaymentMethodTypes(), } ); const eceButton = wcStripeECE.createButton( @@ -156,13 +150,26 @@ jQuery( function ( $ ) { ); } ); - eceButton.on( 'click', function ( event ) { + eceButton.on( 'click', async function ( event ) { // If login is required for checkout, display redirect confirmation dialog. if ( getExpressCheckoutData( 'login_confirmation' ) ) { displayLoginConfirmation( event.expressPaymentType ); return; } + if ( getExpressCheckoutData( 'taxes_based_on_billing' ) ) { + displayExpressCheckoutNotice( + __( + 'Final taxes charged can differ based on your actual billing address when using Express Checkout buttons (Link, Google Pay or Apple Pay).', + 'woocommerce-gateway-stripe' + ), + 'info', + [ 'ece-taxes-info' ] + ); + // Wait for the notice to be displayed before proceeding. + await expressCheckoutNoticeDelay(); + } + if ( getExpressCheckoutData( 'is_product_page' ) ) { const addToCartButton = $( '.single_add_to_cart_button' ); @@ -283,6 +290,7 @@ jQuery( function ( $ ) { currency: getExpressCheckoutData( 'checkout' ) .currency_code, appearance: getExpressCheckoutButtonAppearance(), + locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en', displayItems, order, } ); @@ -460,29 +468,15 @@ jQuery( function ( $ ) { * * @param {PaymentResponse} payment Payment response instance. * @param {string} message Error message to display. + * @param {boolean} isOrderError Whether the error is related to the order creation. */ - abortPayment: ( payment, message ) => { - payment.paymentFailed( { reason: 'fail' } ); + abortPayment: ( payment, message, isOrderError = false ) => { + if ( ! isOrderError ) { + payment.paymentFailed( { reason: 'fail' } ); + } onAbortPaymentHandler( payment, message ); - $( '.woocommerce-error' ).remove(); - - const $container = $( '.woocommerce-notices-wrapper' ).first(); - - if ( $container.length ) { - $container.append( - $( '
' ).text( message ) - ); - - $( 'html, body' ).animate( - { - scrollTop: $container - .find( '.woocommerce-error' ) - .offset().top, - }, - 600 - ); - } + displayExpressCheckoutNotice( message, 'error' ); }, attachProductPageEventListeners: ( elements ) => { @@ -619,7 +613,11 @@ jQuery( function ( $ ) { }; // We don't need to initialize ECE on the checkout page now because it will be initialized by updated_checkout event. - if ( ! getExpressCheckoutData( 'is_checkout_page' ) ) { + if ( + getExpressCheckoutData( 'is_product_page' ) || + getExpressCheckoutData( 'is_pay_for_order' ) || + getExpressCheckoutData( 'is_cart_page' ) + ) { wcStripeECE.init(); } diff --git a/client/entrypoints/payment-gateways/disable-confirmation-modal.js b/client/entrypoints/payment-gateways/disable-confirmation-modal.js index b7221cdc4..473b778ec 100644 --- a/client/entrypoints/payment-gateways/disable-confirmation-modal.js +++ b/client/entrypoints/payment-gateways/disable-confirmation-modal.js @@ -16,8 +16,12 @@ const DisableConfirmationModal = ( { onClose, onConfirm } ) => { paymentRequestEnabledSettings, ] = usePaymentRequestEnabledSettings(); + const enabledMethodIds = enabledPaymentMethodIds.filter( + ( methodId ) => methodId !== 'link' + ); + const mainDialogText = - enabledPaymentMethodIds.length === 0 && paymentRequestEnabledSettings + enabledMethodIds.length === 0 && paymentRequestEnabledSettings ? __( 'Are you sure you want to disable Stripe? Without it, your customers will no longer be able to pay using the express checkouts.', 'woocommerce-gateway-stripe' @@ -51,7 +55,7 @@ const DisableConfirmationModal = ( { onClose, onConfirm } ) => { } >

{ mainDialogText }

- { enabledPaymentMethodIds.length > 0 && ( + { enabledMethodIds.length > 0 && ( <>

{ __( @@ -60,7 +64,7 @@ const DisableConfirmationModal = ( { onClose, onConfirm } ) => { ) }

    - { enabledPaymentMethodIds.map( ( methodId ) => { + { enabledMethodIds.map( ( methodId ) => { return (
  • { + const globalValues = global.wc_stripe_payment_request_settings_params; + beforeEach( () => { usePaymentRequestEnabledSettings.mockReturnValue( getMockPaymentRequestEnabledSettings( true, jest.fn() ) @@ -63,6 +65,18 @@ describe( 'PaymentRequestsSettingsSection', () => { usePaymentRequestLocations.mockReturnValue( getMockPaymentRequestLocations( true, true, true, jest.fn() ) ); + + global.wc_stripe_payment_request_settings_params = { + ...globalValues, + key: 'pk_test_123', + locale: 'en', + is_ece_enabled: true, + }; + } ); + + afterEach( () => { + jest.clearAllMocks(); + global.wc_stripe_payment_request_settings_params = globalValues; } ); it( 'should enable express checkout locations when express checkout is enabled', () => { diff --git a/client/entrypoints/payment-request-settings/__tests__/payment-request-settings.test.js b/client/entrypoints/payment-request-settings/__tests__/payment-request-settings.test.js index 17f68b027..fb15c1e00 100644 --- a/client/entrypoints/payment-request-settings/__tests__/payment-request-settings.test.js +++ b/client/entrypoints/payment-request-settings/__tests__/payment-request-settings.test.js @@ -57,6 +57,7 @@ const getMockPaymentRequestLocations = ( ]; describe( 'PaymentRequestsSettingsSection', () => { + const globalValues = global.wc_stripe_payment_request_settings_params; beforeEach( () => { usePaymentRequestEnabledSettings.mockReturnValue( getMockPaymentRequestEnabledSettings( true, jest.fn() ) @@ -65,6 +66,18 @@ describe( 'PaymentRequestsSettingsSection', () => { usePaymentRequestLocations.mockReturnValue( getMockPaymentRequestLocations( true, true, true, jest.fn() ) ); + + global.wc_stripe_payment_request_settings_params = { + ...globalValues, + key: 'pk_test_123', + locale: 'en', + is_ece_enabled: true, + }; + } ); + + afterEach( () => { + jest.clearAllMocks(); + global.wc_stripe_payment_request_settings_params = globalValues; } ); it( 'renders settings with defaults', () => { diff --git a/client/entrypoints/payment-request-settings/express-checkout-button-preview.js b/client/entrypoints/payment-request-settings/express-checkout-button-preview.js new file mode 100644 index 000000000..9bbdbf32f --- /dev/null +++ b/client/entrypoints/payment-request-settings/express-checkout-button-preview.js @@ -0,0 +1,116 @@ +/* global wc_stripe_payment_request_settings_params */ + +import { __ } from '@wordpress/i18n'; +import { useState, useMemo } from 'react'; +import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { getDefaultBorderRadius } from 'wcstripe/express-checkout/utils'; +import InlineNotice from 'components/inline-notice'; + +const buttonSizeToPxMap = { + small: 40, + default: 48, + large: 56, +}; + +const ExpressCheckoutPreviewComponent = ( { buttonType, theme, size } ) => { + const [ canRenderButtons, setCanRenderButtons ] = useState( true ); + + /* eslint-disable camelcase */ + const stripePromise = useMemo( () => { + return loadStripe( wc_stripe_payment_request_settings_params.key, { + locale: wc_stripe_payment_request_settings_params.locale, + } ); + }, [] ); + /* eslint-enable camelcase */ + + const options = { + mode: 'payment', + amount: 1000, + currency: 'usd', + appearance: { + variables: { + borderRadius: `${ getDefaultBorderRadius() }px`, + spacingUnit: '6px', + }, + }, + }; + + const height = buttonSizeToPxMap[ size ] || buttonSizeToPxMap.medium; + + const mapThemeConfigToButtonTheme = ( paymentMethod, buttonTheme ) => { + switch ( buttonTheme ) { + case 'dark': + return 'black'; + case 'light': + return 'white'; + case 'light-outline': + if ( paymentMethod === 'googlePay' ) { + return 'white'; + } + + return 'white-outline'; + default: + return 'black'; + } + }; + + const type = buttonType === 'default' ? 'plain' : buttonType; + + const buttonOptions = { + buttonHeight: Math.min( Math.max( height, 40 ), 55 ), + buttonTheme: { + googlePay: mapThemeConfigToButtonTheme( 'googlePay', theme ), + applePay: mapThemeConfigToButtonTheme( 'applePay', theme ), + }, + buttonType: { + googlePay: type, + applePay: type, + }, + paymentMethods: { + link: 'never', + googlePay: 'always', + applePay: 'always', + }, + layout: { overflow: 'never' }, + }; + + const onReady = ( { availablePaymentMethods } ) => { + if ( availablePaymentMethods ) { + setCanRenderButtons( true ); + } else { + setCanRenderButtons( false ); + } + }; + + if ( canRenderButtons ) { + return ( +
    + + {} } + onReady={ onReady } + /> + +
    + ); + } + + return ( + + { __( + 'Failed to preview the Apple Pay or Google Pay button. ' + + 'Ensure your store uses HTTPS on a publicly available domain ' + + "and you're viewing this page in a Safari or Chrome browser. " + + 'Your device must be configured to use Apple Pay or Google Pay.', + 'woocommerce-gateway-stripe' + ) } + + ); +}; + +export default ExpressCheckoutPreviewComponent; diff --git a/client/entrypoints/payment-request-settings/payment-request-settings-section.js b/client/entrypoints/payment-request-settings/payment-request-settings-section.js index 6a11100d9..dec5b7377 100644 --- a/client/entrypoints/payment-request-settings/payment-request-settings-section.js +++ b/client/entrypoints/payment-request-settings/payment-request-settings-section.js @@ -1,3 +1,5 @@ +/* global wc_stripe_payment_request_settings_params */ + import { ADMIN_URL, getSetting } from '@woocommerce/settings'; import { __ } from '@wordpress/i18n'; import React, { useMemo } from 'react'; @@ -11,6 +13,7 @@ import interpolateComponents from 'interpolate-components'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import PaymentRequestButtonPreview from './payment-request-button-preview'; +import ExpressCheckoutPreviewComponent from './express-checkout-button-preview'; import { usePaymentRequestEnabledSettings, usePaymentRequestLocations, @@ -130,6 +133,8 @@ const PaymentRequestsSettingsSection = () => { const accountId = useAccount().data?.account?.id; const [ publishableKey ] = useAccountKeysPublishableKey(); const [ testPublishableKey ] = useAccountKeysTestPublishableKey(); + const isECEEnabled = + wc_stripe_payment_request_settings_params.is_ece_enabled; // eslint-disable-line camelcase const stripePromise = useMemo( () => { return loadStripe( @@ -260,9 +265,18 @@ const PaymentRequestsSettingsSection = () => { />

    { __( 'Preview', 'woocommerce-gateway-stripe' ) }

    - - - + { isECEEnabled ? ( + + ) : ( + + + + ) } diff --git a/client/express-checkout/__tests__/event-handler.test.js b/client/express-checkout/__tests__/event-handler.test.js new file mode 100644 index 000000000..801e5c347 --- /dev/null +++ b/client/express-checkout/__tests__/event-handler.test.js @@ -0,0 +1,572 @@ +/** + * Internal dependencies + */ +import { + normalizeLineItems, + normalizeShippingAddress, + normalizeOrderData, + normalizePayForOrderData, +} from '../utils'; +import { + onConfirmHandler, + shippingAddressChangeHandler, + shippingRateChangeHandler, +} from 'wcstripe/express-checkout/event-handler'; + +describe( 'Express checkout event handlers', () => { + describe( 'shippingAddressChangeHandler', () => { + let api; + let event; + let elements; + + beforeEach( () => { + api = { + expressCheckoutECECalculateShippingOptions: jest.fn(), + }; + event = { + address: { + recipient: 'John Doe', + addressLine: [ '123 Main St' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }, + resolve: jest.fn(), + reject: jest.fn(), + }; + elements = { + update: jest.fn(), + }; + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'should handle successful response', async () => { + const response = { + result: 'success', + total: { amount: 1000 }, + shipping_options: [ + { id: 'option_1', label: 'Standard Shipping' }, + ], + displayItems: [ { label: 'Sample Item', amount: 500 } ], + }; + + api.expressCheckoutECECalculateShippingOptions.mockResolvedValue( + response + ); + + await shippingAddressChangeHandler( api, event, elements ); + + const expectedNormalizedAddress = normalizeShippingAddress( + event.address + ); + expect( + api.expressCheckoutECECalculateShippingOptions + ).toHaveBeenCalledWith( expectedNormalizedAddress ); + + const expectedNormalizedLineItems = normalizeLineItems( + response.displayItems + ); + expect( elements.update ).toHaveBeenCalledWith( { amount: 1000 } ); + expect( event.resolve ).toHaveBeenCalledWith( { + shippingRates: response.shipping_options, + lineItems: expectedNormalizedLineItems, + } ); + expect( event.reject ).not.toHaveBeenCalled(); + } ); + + test( 'should handle unsuccessful response', async () => { + const response = { + result: 'error', + }; + + api.expressCheckoutECECalculateShippingOptions.mockResolvedValue( + response + ); + + await shippingAddressChangeHandler( api, event, elements ); + + const expectedNormalizedAddress = normalizeShippingAddress( + event.address + ); + expect( + api.expressCheckoutECECalculateShippingOptions + ).toHaveBeenCalledWith( expectedNormalizedAddress ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + + test( 'should handle API call failure', async () => { + api.expressCheckoutECECalculateShippingOptions.mockRejectedValue( + new Error( 'API error' ) + ); + + await shippingAddressChangeHandler( api, event, elements ); + + const expectedNormalizedAddress = normalizeShippingAddress( + event.address + ); + expect( + api.expressCheckoutECECalculateShippingOptions + ).toHaveBeenCalledWith( expectedNormalizedAddress ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + } ); + + describe( 'shippingRateChangeHandler', () => { + let api; + let event; + let elements; + + beforeEach( () => { + api = { + expressCheckoutUpdateShippingDetails: jest.fn(), + }; + event = { + shippingRate: { + id: 'rate_1', + label: 'Standard Shipping', + amount: 500, + }, + resolve: jest.fn(), + reject: jest.fn(), + }; + elements = { + update: jest.fn(), + }; + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'should handle successful response', async () => { + const response = { + result: 'success', + total: { amount: 1500 }, + displayItems: [ { label: 'Sample Item', amount: 1000 } ], + }; + + api.expressCheckoutUpdateShippingDetails.mockResolvedValue( + response + ); + + await shippingRateChangeHandler( api, event, elements ); + + const expectedNormalizedLineItems = normalizeLineItems( + response.displayItems + ); + expect( + api.expressCheckoutUpdateShippingDetails + ).toHaveBeenCalledWith( event.shippingRate ); + expect( elements.update ).toHaveBeenCalledWith( { amount: 1500 } ); + expect( event.resolve ).toHaveBeenCalledWith( { + lineItems: expectedNormalizedLineItems, + } ); + expect( event.reject ).not.toHaveBeenCalled(); + } ); + + test( 'should handle unsuccessful response', async () => { + const response = { + result: 'error', + }; + + api.expressCheckoutUpdateShippingDetails.mockResolvedValue( + response + ); + + await shippingRateChangeHandler( api, event, elements ); + + expect( + api.expressCheckoutUpdateShippingDetails + ).toHaveBeenCalledWith( event.shippingRate ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + + test( 'should handle API call failure', async () => { + api.expressCheckoutUpdateShippingDetails.mockRejectedValue( + new Error( 'API error' ) + ); + + await shippingRateChangeHandler( api, event, elements ); + + expect( + api.expressCheckoutUpdateShippingDetails + ).toHaveBeenCalledWith( event.shippingRate ); + expect( elements.update ).not.toHaveBeenCalled(); + expect( event.resolve ).not.toHaveBeenCalled(); + expect( event.reject ).toHaveBeenCalled(); + } ); + } ); + + describe( 'onConfirmHandler', () => { + let api; + let stripe; + let elements; + let completePayment; + let abortPayment; + let event; + let order; + + beforeEach( () => { + api = { + expressCheckoutECECreateOrder: jest.fn(), + expressCheckoutECEPayForOrder: jest.fn(), + confirmIntent: jest.fn(), + }; + stripe = { + createPaymentMethod: jest.fn(), + }; + elements = { + submit: jest.fn(), + }; + completePayment = jest.fn(); + abortPayment = jest.fn(); + event = { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + organization: 'Some Company', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + phone: '(123) 456-7890', + }, + shippingAddress: { + name: 'John Doe', + organization: 'Some Company', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + }, + shippingRate: { id: 'rate_1' }, + expressPaymentType: 'express', + }; + order = 123; + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + test( 'should abort payment if elements.submit fails', async () => { + elements.submit.mockResolvedValue( { + error: { message: 'Submit error' }, + } ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( elements.submit ).toHaveBeenCalled(); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Submit error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment if stripe.createPaymentMethod fails', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + error: { message: 'Payment method error' }, + } ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( elements.submit ).toHaveBeenCalled(); + expect( stripe.createPaymentMethod ).toHaveBeenCalledWith( { + elements, + } ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Payment method error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment if expressCheckoutECECreateOrder fails', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'error', + messages: 'Order creation error', + } ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + const expectedOrderData = normalizeOrderData( event, 'pm_123' ); + expect( api.expressCheckoutECECreateOrder ).toHaveBeenCalledWith( + expectedOrderData + ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Order creation error', + true + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should complete payment if confirmationRequest is true', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockReturnValue( true ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( completePayment ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( abortPayment ).not.toHaveBeenCalled(); + } ); + + test( 'should complete payment if confirmationRequest returns a redirect URL', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockResolvedValue( + 'https://example.com/confirmation_redirect' + ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( completePayment ).toHaveBeenCalledWith( + 'https://example.com/confirmation_redirect' + ); + expect( abortPayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment if confirmIntent throws an error', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECECreateOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockRejectedValue( + new Error( 'Intent confirmation error' ) + ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Intent confirmation error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment if expressCheckoutECEPayForOrder fails', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECEPayForOrder.mockResolvedValue( { + result: 'error', + messages: 'Order creation error', + } ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event, + order + ); + + const expectedOrderData = normalizePayForOrderData( + event, + 'pm_123' + ); + expect( api.expressCheckoutECEPayForOrder ).toHaveBeenCalledWith( + 123, + expectedOrderData + ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Order creation error', + true + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + + test( 'should complete payment (pay for order) if confirmationRequest is true', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECEPayForOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockReturnValue( true ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event, + order + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( completePayment ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( abortPayment ).not.toHaveBeenCalled(); + } ); + + test( 'should complete payment (pay for order) if confirmationRequest returns a redirect URL', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECEPayForOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockResolvedValue( + 'https://example.com/confirmation_redirect' + ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event, + order + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( completePayment ).toHaveBeenCalledWith( + 'https://example.com/confirmation_redirect' + ); + expect( abortPayment ).not.toHaveBeenCalled(); + } ); + + test( 'should abort payment (pay for order) if confirmIntent throws an error', async () => { + elements.submit.mockResolvedValue( {} ); + stripe.createPaymentMethod.mockResolvedValue( { + paymentMethod: { id: 'pm_123' }, + } ); + api.expressCheckoutECEPayForOrder.mockResolvedValue( { + result: 'success', + redirect: 'https://example.com/redirect', + } ); + api.confirmIntent.mockRejectedValue( + new Error( 'Intent confirmation error' ) + ); + + await onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event, + order + ); + + expect( api.confirmIntent ).toHaveBeenCalledWith( + 'https://example.com/redirect' + ); + expect( abortPayment ).toHaveBeenCalledWith( + event, + 'Intent confirmation error' + ); + expect( completePayment ).not.toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/client/express-checkout/__tests__/tracking.test.js b/client/express-checkout/__tests__/tracking.test.js new file mode 100644 index 000000000..30c311720 --- /dev/null +++ b/client/express-checkout/__tests__/tracking.test.js @@ -0,0 +1,19 @@ +import { trackExpressCheckoutButtonClick } from 'wcstripe/express-checkout/tracking'; +import { recordEvent } from 'wcstripe/tracking'; + +jest.mock( 'wcstripe/tracking', () => ( { + recordEvent: jest.fn(), +} ) ); + +describe( 'Express checkout tracking class', () => { + describe( 'trackExpressCheckoutButtonClick', () => { + test( 'no event found to track', () => { + trackExpressCheckoutButtonClick( 'foo', 'bar' ); + expect( recordEvent ).not.toBeCalled(); + } ); + test( 'should track checkout button click', () => { + trackExpressCheckoutButtonClick( 'google_pay', 'bar' ); + expect( recordEvent ).toBeCalled(); + } ); + } ); +} ); diff --git a/client/express-checkout/event-handler.js b/client/express-checkout/event-handler.js index 79defef53..2caf8f317 100644 --- a/client/express-checkout/event-handler.js +++ b/client/express-checkout/event-handler.js @@ -92,7 +92,8 @@ export const onConfirmHandler = async ( if ( orderResponse.result !== 'success' ) { return abortPayment( event, - getErrorMessageFromNotice( orderResponse.messages ) + getErrorMessageFromNotice( orderResponse.messages ), + true ); } diff --git a/client/express-checkout/tracking.js b/client/express-checkout/tracking.js index d7b000ce5..7b074a926 100644 --- a/client/express-checkout/tracking.js +++ b/client/express-checkout/tracking.js @@ -6,6 +6,7 @@ export const trackExpressCheckoutButtonClick = ( paymentMethod, source ) => { const expressPaymentTypeEvents = { google_pay: 'gpay_button_click', apple_pay: 'applepay_button_click', + link: 'link_button_click', }; const event = expressPaymentTypeEvents[ paymentMethod ]; @@ -22,6 +23,7 @@ export const trackExpressCheckoutButtonLoad = debounce( const expressPaymentTypeEvents = { googlePay: 'gpay_button_load', applePay: 'applepay_button_load', + link: 'link_button_load', }; for ( const paymentMethod of paymentMethods ) { diff --git a/client/express-checkout/utils/__tests__/index.test.js b/client/express-checkout/utils/__tests__/index.test.js new file mode 100644 index 000000000..bb119fb34 --- /dev/null +++ b/client/express-checkout/utils/__tests__/index.test.js @@ -0,0 +1,86 @@ +/** + * Internal dependencies + */ +import { screen, render } from '@testing-library/react'; +import { + displayExpressCheckoutNotice, + getErrorMessageFromNotice, + getExpressCheckoutData, +} from '..'; + +describe( 'Express checkout utils', () => { + test( 'getExpressCheckoutData returns null for missing option', () => { + expect( + getExpressCheckoutData( + // Force wrong usage, just in case this is called from JS with incorrect params. + 'does-not-exist' + ) + ).toBeNull(); + } ); + + test( 'getExpressCheckoutData returns correct value for present option', () => { + // We don't care that the implementation is partial for the purposes of the test, so + // the type assertion is fine. + window.wc_stripe_express_checkout_params = { + ajax_url: 'test', + }; + + expect( getExpressCheckoutData( 'ajax_url' ) ).toBe( 'test' ); + } ); + + test( 'getErrorMessageFromNotice strips formatting', () => { + const notice = '

    Error: Payment failed.

    '; + expect( getErrorMessageFromNotice( notice ) ).toBe( + 'Error: Payment failed.' + ); + } ); + + test( 'getErrorMessageFromNotice strips scripts', () => { + const notice = + '

    Error: Payment failed.

    '; + expect( getErrorMessageFromNotice( notice ) ).toBe( + 'Error: Payment failed.alert("hello")' + ); + } ); + + describe( 'displayExpressCheckoutNotice', () => { + afterEach( () => { + document.getElementsByTagName( 'body' )[ 0 ].innerHTML = ''; + } ); + + const additionalClasses = [ 'class-2', 'class-3' ]; + const createWrapper = () => { + const wrapper = document.createElement( 'div' ); + wrapper.classList.add( 'woocommerce-notices-wrapper' ); + document.body.appendChild( wrapper ); + }; + + test( 'with info', async () => { + function App() { + createWrapper(); + displayExpressCheckoutNotice( + 'Test message', + 'info', + additionalClasses + ); + return
    ; + } + render( ); + expect( screen.queryByRole( 'note' ) ).toBeInTheDocument(); + } ); + + test( 'with error', () => { + function App() { + createWrapper(); + displayExpressCheckoutNotice( + 'Test message', + 'error', + additionalClasses + ); + return
    ; + } + render( ); + expect( screen.queryByRole( 'note' ) ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/client/express-checkout/utils/__tests__/normalize.test.js b/client/express-checkout/utils/__tests__/normalize.test.js new file mode 100644 index 000000000..d39d15a9e --- /dev/null +++ b/client/express-checkout/utils/__tests__/normalize.test.js @@ -0,0 +1,499 @@ +/** + * Internal dependencies + */ +import { + normalizeLineItems, + normalizeOrderData, + normalizePayForOrderData, + normalizeShippingAddress, +} from '../normalize'; + +describe( 'Express checkout normalization', () => { + describe( 'normalizeLineItems', () => { + test( 'normalizes blocks array properly', () => { + const displayItems = [ + { + label: 'Item 1', + value: 100, + }, + { + label: 'Item 2', + value: 200, + }, + { + label: 'Item 3', + valueWithTax: 300, + value: 200, + }, + ]; + + // Extra items in the array are expected since they're not stripped. + const expected = [ + { + name: 'Item 1', + amount: 100, + }, + { + name: 'Item 2', + amount: 200, + }, + { + name: 'Item 3', + amount: 200, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + + test( 'normalizes shortcode array properly', () => { + const displayItems = [ + { + label: 'Item 1', + amount: 100, + }, + { + label: 'Item 2', + amount: 200, + }, + { + label: 'Item 3', + amount: 300, + }, + ]; + + const expected = [ + { + name: 'Item 1', + amount: 100, + }, + { + name: 'Item 2', + amount: 200, + }, + { + name: 'Item 3', + amount: 300, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + + test( 'normalizes discount line item properly', () => { + const displayItems = [ + { + label: 'Item 1', + amount: 100, + }, + { + label: 'Item 2', + amount: 200, + }, + { + label: 'Item 3', + amount: 300, + }, + { + key: 'total_discount', + label: 'Discount', + amount: 50, + }, + ]; + + const expected = [ + { + name: 'Item 1', + amount: 100, + }, + { + name: 'Item 2', + amount: 200, + }, + { + name: 'Item 3', + amount: 300, + }, + { + name: 'Discount', + amount: -50, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + } ); + + describe( 'normalizeOrderData', () => { + test( 'should normalize order data with complete event and paymentMethodId', () => { + const event = { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + organization: 'Some Company', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + phone: '(123) 456-7890', + }, + shippingAddress: { + name: 'John Doe', + organization: 'Some Company', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + }, + shippingRate: { id: 'rate_1' }, + expressPaymentType: 'express', + }; + + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: 'John', + billing_last_name: 'Doe', + billing_company: 'Some Company', + billing_email: 'john.doe@example.com', + billing_phone: '1234567890', + billing_country: 'US', + billing_address_1: '123 Main St', + billing_address_2: 'Apt 4B', + billing_city: 'New York', + billing_state: 'NY', + billing_postcode: '10001', + shipping_first_name: 'John', + shipping_last_name: 'Doe', + shipping_company: 'Some Company', + shipping_phone: '1234567890', + shipping_country: 'US', + shipping_address_1: '123 Main St', + shipping_address_2: 'Apt 4B', + shipping_city: 'New York', + shipping_state: 'NY', + shipping_postcode: '10001', + shipping_method: [ 'rate_1' ], + order_comments: '', + payment_method: 'stripe', + ship_to_different_address: 1, + terms: 1, + 'wc-stripe-is-deferred-intent': true, + 'wc-stripe-payment-method': paymentMethodId, + express_checkout_type: 'express', + express_payment_type: 'express', + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + + test( 'should normalize order data with missing optional event fields', () => { + const event = {}; + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: '', + billing_last_name: '-', + billing_company: '', + billing_email: '', + billing_phone: '', + billing_country: '', + billing_address_1: '', + billing_address_2: '', + billing_city: '', + billing_state: '', + billing_postcode: '', + shipping_first_name: '', + shipping_last_name: '', + shipping_company: '', + shipping_phone: '', + shipping_country: '', + shipping_address_1: '', + shipping_address_2: '', + shipping_city: '', + shipping_state: '', + shipping_postcode: '', + shipping_method: [ null ], + order_comments: '', + payment_method: 'stripe', + ship_to_different_address: 1, + terms: 1, + 'wc-stripe-is-deferred-intent': true, + 'wc-stripe-payment-method': paymentMethodId, + express_payment_type: undefined, + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + + test( 'should normalize order data with minimum required fields', () => { + const event = { + billingDetails: { + name: 'John', + }, + }; + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: 'John', + billing_last_name: '', + billing_company: '', + billing_email: '', + billing_phone: '', + billing_country: '', + billing_address_1: '', + billing_address_2: '', + billing_city: '', + billing_state: '', + billing_postcode: '', + shipping_first_name: '', + shipping_last_name: '', + shipping_company: '', + shipping_phone: '', + shipping_country: '', + shipping_address_1: '', + shipping_address_2: '', + shipping_city: '', + shipping_state: '', + shipping_postcode: '', + shipping_method: [ null ], + order_comments: '', + payment_method: 'stripe', + ship_to_different_address: 1, + terms: 1, + 'wc-stripe-is-deferred-intent': true, + 'wc-stripe-payment-method': paymentMethodId, + express_payment_type: undefined, + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + } ); + + describe( 'normalizePayForOrderData', () => { + test( 'should normalize pay for order data with complete event and paymentMethodId', () => { + const event = { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + organization: 'Some Company', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + phone: '(123) 456-7890', + }, + shippingAddress: { + name: 'John Doe', + organization: 'Some Company', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + }, + shippingRate: { id: 'rate_1' }, + expressPaymentType: 'express', + }; + + expect( normalizePayForOrderData( event, 'pm_123456' ) ).toEqual( { + payment_method: 'stripe', + 'wc-stripe-is-deferred-intent': true, + 'wc-stripe-payment-method': 'pm_123456', + express_payment_type: 'express', + } ); + } ); + + test( 'should normalize pay for order data with empty event and empty payment method', () => { + const event = {}; + const paymentMethodId = ''; + + expect( + normalizePayForOrderData( event, paymentMethodId ) + ).toEqual( { + payment_method: 'stripe', + 'wc-stripe-is-deferred-intent': true, + 'wc-stripe-payment-method': '', + express_payment_type: undefined, + } ); + } ); + } ); + + describe( 'normalizeShippingAddress', () => { + test( 'should normalize shipping address with all fields present', () => { + const shippingAddress = { + recipient: 'John Doe', + addressLine: [ '123 Main St', 'Apt 4B' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe', + company: '', + address_1: '123 Main St', + address_2: 'Apt 4B', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with only recipient name', () => { + const shippingAddress = { + recipient: 'John', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: '', + company: '', + address_1: '', + address_2: '', + city: '', + state: '', + country: '', + postcode: '', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with missing recipient name', () => { + const shippingAddress = { + addressLine: [ '123 Main St' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: '', + last_name: '', + company: '', + address_1: '123 Main St', + address_2: '', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with empty addressLine', () => { + const shippingAddress = { + recipient: 'John Doe', + addressLine: [], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe', + company: '', + address_1: '', + address_2: '', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize an empty shipping address', () => { + const shippingAddress = {}; + + const expectedNormalizedAddress = { + first_name: '', + last_name: '', + company: '', + address_1: '', + address_2: '', + city: '', + state: '', + country: '', + postcode: '', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize a shipping address with a multi-word recipient name', () => { + const shippingAddress = { + recipient: 'John Doe Smith', + addressLine: [ '123 Main St', 'Apt 4B' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe Smith', + company: '', + address_1: '123 Main St', + address_2: 'Apt 4B', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + } ); +} ); diff --git a/client/express-checkout/utils/check-payment-method-availability.js b/client/express-checkout/utils/check-payment-method-availability.js index b52bf23b3..a75a8aa14 100644 --- a/client/express-checkout/utils/check-payment-method-availability.js +++ b/client/express-checkout/utils/check-payment-method-availability.js @@ -1,6 +1,7 @@ import ReactDOM from 'react-dom'; import { ExpressCheckoutElement, Elements } from '@stripe/react-stripe-js'; import { memoize } from 'lodash'; +import { getPaymentMethodTypesForExpressMethod } from 'wcstripe/express-checkout/utils'; export const checkPaymentMethodIsAvailable = memoize( ( paymentMethod, api, cart, resolve ) => { @@ -22,6 +23,9 @@ export const checkPaymentMethodIsAvailable = memoize( paymentMethodCreation: 'manual', amount: Number( cart.cartTotals.total_price ), currency: cart.cartTotals.currency_code.toLowerCase(), + paymentMethodTypes: getPaymentMethodTypesForExpressMethod( + paymentMethod + ), } } > { paymentMethods: { applePay: 'always', googlePay: 'always', - link: 'never', + link: 'auto', paypal: 'never', amazonPay: 'never', }, @@ -250,3 +254,116 @@ const getRequiredFieldDataFromShortcodeCheckoutForm = ( data ) => { return data; }; + +/** + * Get array of payment method types to use with intent. Filtering out the method types not part of Express Checkout. + * + * @see https://docs.stripe.com/elements/express-checkout-element/accept-a-payment#enable-payment-methods - lists the method types + * supported and which ones are required by each Express Checkout method. + * + * @param {string} paymentMethodType Payment method type Stripe ID. + * @return {Array} Array of payment method types to use with intent, for Express Checkout. + */ +export const getExpressPaymentMethodTypes = ( paymentMethodType = null ) => { + const expressPaymentMethodTypes = getPaymentMethodTypes( + paymentMethodType + ).filter( ( type ) => [ 'paypal', 'amazon_pay', 'card' ].includes( type ) ); + + if ( isLinkEnabled() ) { + expressPaymentMethodTypes.push( 'link' ); + } + + return expressPaymentMethodTypes; +}; + +/** + * Fetches the payment method types required to process a payment for an Express method. + * + * @see https://docs.stripe.com/elements/express-checkout-element/accept-a-payment#enable-payment-methods - lists the method types + * supported and which ones are required by each Express Checkout method. + * + * @param {*} paymentMethodType The express payment method type. eg 'link', 'googlePay', or 'applePay'. + * @return {Array} Array of payment method types necessary to process a payment for an Express method. + */ +export const getPaymentMethodTypesForExpressMethod = ( paymentMethodType ) => { + const paymentMethodsConfig = getBlocksConfiguration()?.paymentMethodsConfig; + const paymentMethodTypes = []; + + if ( ! paymentMethodsConfig ) { + return paymentMethodTypes; + } + + // All express payment methods require 'card' payments. Add it if it's enabled. + if ( paymentMethodsConfig?.card !== undefined ) { + paymentMethodTypes.push( 'card' ); + } + + // Add 'link' payment method type if enabled and requested. + if ( + paymentMethodType === 'link' && + isLinkEnabled( paymentMethodsConfig ) + ) { + paymentMethodTypes.push( 'link' ); + } + + return paymentMethodTypes; +}; + +/** + * Display a notice on the checkout page (for Express Checkout Element). + * + * @param {string} message The message to display. + * @param {string} type The type of notice. + * @param {Array} additionalClasses Additional classes to add to the notice. + */ +export const displayExpressCheckoutNotice = ( + message, + type, + additionalClasses +) => { + const isBlockCheckout = getExpressCheckoutData( 'has_block' ); + const mainNoticeClass = `woocommerce-${ type }`; + let classNames = [ mainNoticeClass ]; + if ( additionalClasses ) { + classNames = classNames.concat( additionalClasses ); + } + + // Remove any existing notices. + jQuery( '.' + classNames.join( '.' ) ).remove(); + + const containerClass = isBlockCheckout + ? 'wc-block-components-main' + : 'woocommerce-notices-wrapper'; + const $container = jQuery( '.' + containerClass ).first(); + + if ( $container.length ) { + const note = jQuery( + `
    ` + ).text( message ); + if ( isBlockCheckout ) { + $container.prepend( note ); + } else { + $container.append( note ); + } + + // Scroll to notices. + jQuery( 'html, body' ).animate( + { + scrollTop: $container.find( `.${ mainNoticeClass }` ).offset() + .top, + }, + 600 + ); + } +}; + +/** + * Delay for a short period of time before proceeding with the checkout process. + * + * @return {Promise} A promise that resolves after the delay. + */ +export const expressCheckoutNoticeDelay = async () => { + await new Promise( ( resolve ) => + setTimeout( resolve, EXPRESS_CHECKOUT_NOTICE_DELAY ) + ); +}; diff --git a/client/payment-method-icons/apple-pay/icon.svg b/client/payment-method-icons/apple-pay/icon-black.svg similarity index 100% rename from client/payment-method-icons/apple-pay/icon.svg rename to client/payment-method-icons/apple-pay/icon-black.svg diff --git a/client/payment-method-icons/apple-pay/icon-white.svg b/client/payment-method-icons/apple-pay/icon-white.svg new file mode 100644 index 000000000..387b2d584 --- /dev/null +++ b/client/payment-method-icons/apple-pay/icon-white.svg @@ -0,0 +1 @@ + diff --git a/client/payment-method-icons/apple-pay/index.js b/client/payment-method-icons/apple-pay/index.js index da5f3b30f..816de0d07 100644 --- a/client/payment-method-icons/apple-pay/index.js +++ b/client/payment-method-icons/apple-pay/index.js @@ -1,6 +1,6 @@ import React from 'react'; import IconWithShell from '../styles/icon-with-shell'; -import icon from './icon.svg'; +import icon from './icon-black.svg'; const ApplePayIcon = ( props ) => ; diff --git a/client/payment-method-icons/google-pay/icon-white.svg b/client/payment-method-icons/google-pay/icon-white.svg new file mode 100644 index 000000000..30afe7f7c --- /dev/null +++ b/client/payment-method-icons/google-pay/icon-white.svg @@ -0,0 +1 @@ + diff --git a/client/payment-method-icons/link/icon-black.svg b/client/payment-method-icons/link/icon-black.svg new file mode 100644 index 000000000..f9f427828 --- /dev/null +++ b/client/payment-method-icons/link/icon-black.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/client/settings/payment-request-section/index.js b/client/settings/payment-request-section/index.js index 99f4b2ab1..7cde86b09 100644 --- a/client/settings/payment-request-section/index.js +++ b/client/settings/payment-request-section/index.js @@ -40,6 +40,7 @@ const PaymentRequestSection = () => { } }; + const displayExpressPaymentMethods = enabledMethodIds.includes( 'card' ); const displayLinkPaymentMethod = enabledMethodIds.includes( 'card' ) && availablePaymentMethodIds.includes( linkMethodID ); @@ -53,70 +54,83 @@ const PaymentRequestSection = () => {
      -
    • -
      - -
      -
      - -
      -
      -
      - { __( - 'Apple Pay / Google Pay', - 'woocommerce-gateway-stripe' - ) } + { ! displayExpressPaymentMethods && + ! displayLinkPaymentMethod && ( +
    • +
      + { __( + 'Credit card / debit card must be enabled as a payment method in order to use Express Checkout.', + 'woocommerce-gateway-stripe' + ) } +
      +
    • + ) } + { displayExpressPaymentMethods && ( +
    • +
      +
      -
      - { - /* eslint-disable jsx-a11y/anchor-has-content */ - interpolateComponents( { - mixedString: __( - 'Boost sales by offering a fast, simple, and secure checkout experience.' + - 'By enabling this feature, you agree to {{stripeLink}}Stripe{{/stripeLink}}, ' + - "{{appleLink}}Apple{{/appleLink}}, and {{googleLink}}Google{{/googleLink}}'s terms of use.", - 'woocommerce-gateway-stripe' - ), - components: { - stripeLink: ( - - ), - appleLink: ( - - ), - googleLink: ( - +
      + +
      +
      -
      - -
    • + + + ) } { displayLinkPaymentMethod && (
    • diff --git a/client/stripe-utils/cash-app-limit-notice-handler.js b/client/stripe-utils/cash-app-limit-notice-handler.js index 34ec2d536..e2b5cad76 100644 --- a/client/stripe-utils/cash-app-limit-notice-handler.js +++ b/client/stripe-utils/cash-app-limit-notice-handler.js @@ -1,8 +1,6 @@ import { __ } from '@wordpress/i18n'; import { callWhenElementIsAvailable } from 'wcstripe/blocks/upe/call-when-element-is-available'; - -/** The amount threshold for displaying the notice. */ -export const CASH_APP_NOTICE_AMOUNT_THRESHOLD = 200000; +import { CASH_APP_NOTICE_AMOUNT_THRESHOLD } from 'wcstripe/data/constants'; /** The class name for the limit notice element. */ const LIMIT_NOTICE_CLASSNAME = 'wc-block-checkout__payment-method-limit-notice'; diff --git a/client/stripe-utils/utils.js b/client/stripe-utils/utils.js index 302d388bf..8611acdd3 100644 --- a/client/stripe-utils/utils.js +++ b/client/stripe-utils/utils.js @@ -220,6 +220,8 @@ export { getStripeServerData, getErrorMessageForTypeAndCode }; * @return {boolean} True, if enabled; false otherwise. */ export const isLinkEnabled = ( paymentMethodsConfig ) => { + paymentMethodsConfig = + paymentMethodsConfig || getStripeServerData()?.paymentMethodsConfig; return ( paymentMethodsConfig?.link !== undefined && paymentMethodsConfig?.card !== undefined diff --git a/includes/abstracts/abstract-wc-stripe-payment-gateway.php b/includes/abstracts/abstract-wc-stripe-payment-gateway.php index 251506e9d..ecf9778a8 100644 --- a/includes/abstracts/abstract-wc-stripe-payment-gateway.php +++ b/includes/abstracts/abstract-wc-stripe-payment-gateway.php @@ -481,8 +481,9 @@ public function generate_payment_request( $order, $prepared_payment_method ) { $metadata = [ __( 'customer_name', 'woocommerce-gateway-stripe' ) => sanitize_text_field( $billing_first_name ) . ' ' . sanitize_text_field( $billing_last_name ), __( 'customer_email', 'woocommerce-gateway-stripe' ) => sanitize_email( $billing_email ), - 'order_id' => $order->get_order_number(), - 'site_url' => esc_url( get_site_url() ), + 'order_id' => $order->get_order_number(), + 'site_url' => esc_url( get_site_url() ), + 'signature' => $this->get_order_signature( $order ), ]; if ( $this->has_subscription( $order->get_id() ) ) { @@ -2324,4 +2325,23 @@ public function get_balance_transaction_id_from_charge( $charge ) { return $balance_transaction_id; } + + /** + * Generates a unique signature for an order. + * + * This signature is included as metadata in Stripe requests and used to identify the order when webhooks are received. + * + * @param WC_Order $order The Order object. + * @return string The order's unique signature. Format: order_id:md5(order_id-order_key-customer_id-order_total). + */ + protected function get_order_signature( $order ) { + $signature = [ + absint( $order->get_id() ), + $order->get_order_key(), + $order->get_customer_id() ?? '', + WC_Stripe_Helper::get_stripe_amount( $order->get_total(), $order->get_currency() ), + ]; + + return sprintf( '%d:%s', $order->get_id(), md5( implode( '-', $signature ) ) ); + } } diff --git a/includes/admin/class-wc-stripe-payment-requests-controller.php b/includes/admin/class-wc-stripe-payment-requests-controller.php index 83f0bba71..beb2da974 100644 --- a/includes/admin/class-wc-stripe-payment-requests-controller.php +++ b/includes/admin/class-wc-stripe-payment-requests-controller.php @@ -39,6 +39,18 @@ public function admin_scripts() { ); wp_enqueue_script( 'wc-stripe-payment-request-settings' ); + $stripe_settings = WC_Stripe_Helper::get_stripe_settings(); + $params = [ + 'key' => 'yes' === $stripe_settings['testmode'] ? $stripe_settings['test_publishable_key'] : $stripe_settings['publishable_key'], + 'locale' => WC_Stripe_Helper::convert_wc_locale_to_stripe_locale( get_locale() ), + 'is_ece_enabled' => WC_Stripe_Feature_Flags::is_stripe_ece_enabled(), + ]; + wp_localize_script( + 'wc-stripe-payment-request-settings', + 'wc_stripe_payment_request_settings_params', + $params + ); + wp_register_style( 'wc-stripe-payment-request-settings', plugins_url( 'build/payment_requests_settings.css', WC_STRIPE_MAIN_FILE ), diff --git a/includes/admin/stripe-settings.php b/includes/admin/stripe-settings.php index 5d80a4637..f13e45852 100644 --- a/includes/admin/stripe-settings.php +++ b/includes/admin/stripe-settings.php @@ -138,7 +138,7 @@ 'label' => __( 'Button Type', 'woocommerce-gateway-stripe' ), 'type' => 'select', 'description' => __( 'Select the button type you would like to show.', 'woocommerce-gateway-stripe' ), - 'default' => 'buy', + 'default' => 'default', 'desc_tip' => true, 'options' => [ 'default' => __( 'Default', 'woocommerce-gateway-stripe' ), diff --git a/includes/class-wc-gateway-stripe.php b/includes/class-wc-gateway-stripe.php index ad59c91d2..3c0cd39d9 100644 --- a/includes/class-wc-gateway-stripe.php +++ b/includes/class-wc-gateway-stripe.php @@ -142,7 +142,7 @@ public function __construct() { */ public function get_title() { // Change the title on the payment methods settings page to include the number of enabled payment methods. - if ( isset( $_GET['page'] ) && 'wc-settings' === $_GET['page'] ) { + if ( ! WC_Stripe_Feature_Flags::is_upe_checkout_enabled() && isset( $_GET['page'] ) && 'wc-settings' === $_GET['page'] ) { $enabled_payment_methods_count = count( WC_Stripe_Helper::get_legacy_enabled_payment_method_ids() ); $this->title = $enabled_payment_methods_count ? /* translators: $1. Count of enabled payment methods. */ diff --git a/includes/class-wc-stripe-blocks-support.php b/includes/class-wc-stripe-blocks-support.php index fa93dcdda..0fd0a49bb 100644 --- a/includes/class-wc-stripe-blocks-support.php +++ b/includes/class-wc-stripe-blocks-support.php @@ -188,13 +188,14 @@ public function get_payment_method_data() { $js_params, // Blocks-specific options [ - 'icons' => $this->get_icons(), - 'supports' => $this->get_supported_features(), - 'showSavedCards' => $this->get_show_saved_cards(), - 'showSaveOption' => $this->get_show_save_option(), - 'isAdmin' => is_admin(), - 'shouldShowPaymentRequestButton' => $this->should_show_payment_request_button(), - 'button' => [ + 'icons' => $this->get_icons(), + 'supports' => $this->get_supported_features(), + 'showSavedCards' => $this->get_show_saved_cards(), + 'showSaveOption' => $this->get_show_save_option(), + 'isAdmin' => is_admin(), + 'shouldShowPaymentRequestButton' => $this->should_show_payment_request_button(), + 'shouldShowExpressCheckoutButton' => $this->should_show_express_checkout_button(), + 'button' => [ 'customLabel' => $this->payment_request_configuration->get_button_label(), ], ] @@ -255,10 +256,15 @@ private function should_show_payment_request_button() { * @return boolean True if ECEs should be displayed, false otherwise. */ private function should_show_express_checkout_button() { + // Don't show if ECEs are turned off in settings. + if ( ! $this->express_checkout_configuration->express_checkout_helper->is_express_checkout_enabled() ) { + return false; + } + // Don't show if ECEs are supposed to be hidden on the cart page. if ( has_block( 'woocommerce/cart' ) - && ! $this->express_checkout_configuration->express_checkout_helper->should_show_ece_on_cart_page()() + && ! $this->express_checkout_configuration->express_checkout_helper->should_show_ece_on_cart_page() ) { return false; } diff --git a/includes/class-wc-stripe-helper.php b/includes/class-wc-stripe-helper.php index ef70c557e..c36d5faf3 100644 --- a/includes/class-wc-stripe-helper.php +++ b/includes/class-wc-stripe-helper.php @@ -238,32 +238,33 @@ public static function get_localized_messages() { return apply_filters( 'wc_stripe_localized_messages', [ - 'invalid_number' => __( 'The card number is not a valid credit card number.', 'woocommerce-gateway-stripe' ), - 'invalid_expiry_month' => __( 'The card\'s expiration month is invalid.', 'woocommerce-gateway-stripe' ), - 'invalid_expiry_year' => __( 'The card\'s expiration year is invalid.', 'woocommerce-gateway-stripe' ), - 'invalid_cvc' => __( 'The card\'s security code is invalid.', 'woocommerce-gateway-stripe' ), - 'incorrect_number' => __( 'The card number is incorrect.', 'woocommerce-gateway-stripe' ), - 'incomplete_number' => __( 'The card number is incomplete.', 'woocommerce-gateway-stripe' ), - 'incomplete_cvc' => __( 'The card\'s security code is incomplete.', 'woocommerce-gateway-stripe' ), - 'incomplete_expiry' => __( 'The card\'s expiration date is incomplete.', 'woocommerce-gateway-stripe' ), - 'expired_card' => __( 'The card has expired.', 'woocommerce-gateway-stripe' ), - 'incorrect_cvc' => __( 'The card\'s security code is incorrect.', 'woocommerce-gateway-stripe' ), - 'incorrect_zip' => __( 'The card\'s zip code failed validation.', 'woocommerce-gateway-stripe' ), - 'postal_code_invalid' => __( 'Invalid zip code, please correct and try again', 'woocommerce-gateway-stripe' ), - 'invalid_expiry_year_past' => __( 'The card\'s expiration year is in the past', 'woocommerce-gateway-stripe' ), - 'card_declined' => __( 'The card was declined.', 'woocommerce-gateway-stripe' ), - 'missing' => __( 'There is no card on a customer that is being charged.', 'woocommerce-gateway-stripe' ), - 'processing_error' => __( 'An error occurred while processing the card.', 'woocommerce-gateway-stripe' ), - 'invalid_sofort_country' => __( 'The billing country is not accepted by Sofort. Please try another country.', 'woocommerce-gateway-stripe' ), - 'email_invalid' => __( 'Invalid email address, please correct and try again.', 'woocommerce-gateway-stripe' ), - 'invalid_request_error' => is_add_payment_method_page() + 'invalid_number' => __( 'The card number is not a valid credit card number.', 'woocommerce-gateway-stripe' ), + 'invalid_expiry_month' => __( 'The card\'s expiration month is invalid.', 'woocommerce-gateway-stripe' ), + 'invalid_expiry_year' => __( 'The card\'s expiration year is invalid.', 'woocommerce-gateway-stripe' ), + 'invalid_cvc' => __( 'The card\'s security code is invalid.', 'woocommerce-gateway-stripe' ), + 'incorrect_number' => __( 'The card number is incorrect.', 'woocommerce-gateway-stripe' ), + 'incomplete_number' => __( 'The card number is incomplete.', 'woocommerce-gateway-stripe' ), + 'incomplete_cvc' => __( 'The card\'s security code is incomplete.', 'woocommerce-gateway-stripe' ), + 'incomplete_expiry' => __( 'The card\'s expiration date is incomplete.', 'woocommerce-gateway-stripe' ), + 'expired_card' => __( 'The card has expired.', 'woocommerce-gateway-stripe' ), + 'incorrect_cvc' => __( 'The card\'s security code is incorrect.', 'woocommerce-gateway-stripe' ), + 'incorrect_zip' => __( 'The card\'s zip code failed validation.', 'woocommerce-gateway-stripe' ), + 'postal_code_invalid' => __( 'Invalid zip code, please correct and try again', 'woocommerce-gateway-stripe' ), + 'invalid_expiry_year_past' => __( 'The card\'s expiration year is in the past', 'woocommerce-gateway-stripe' ), + 'card_declined' => __( 'The card was declined.', 'woocommerce-gateway-stripe' ), + 'missing' => __( 'There is no card on a customer that is being charged.', 'woocommerce-gateway-stripe' ), + 'processing_error' => __( 'An error occurred while processing the card.', 'woocommerce-gateway-stripe' ), + 'invalid_sofort_country' => __( 'The billing country is not accepted by Sofort. Please try another country.', 'woocommerce-gateway-stripe' ), + 'email_invalid' => __( 'Invalid email address, please correct and try again.', 'woocommerce-gateway-stripe' ), + 'invalid_request_error' => is_add_payment_method_page() ? __( 'Unable to save this payment method, please try again or use alternative method.', 'woocommerce-gateway-stripe' ) : __( 'Unable to process this payment, please try again or use alternative method.', 'woocommerce-gateway-stripe' ), - 'amount_too_large' => __( 'The order total is too high for this payment method', 'woocommerce-gateway-stripe' ), - 'amount_too_small' => __( 'The order total is too low for this payment method', 'woocommerce-gateway-stripe' ), - 'country_code_invalid' => __( 'Invalid country code, please try again with a valid country code', 'woocommerce-gateway-stripe' ), - 'tax_id_invalid' => __( 'Invalid Tax Id, please try again with a valid tax id', 'woocommerce-gateway-stripe' ), - 'invalid_wallet_type' => __( 'Invalid wallet payment type, please try again or use an alternative method.', 'woocommerce-gateway-stripe' ), + 'amount_too_large' => __( 'The order total is too high for this payment method', 'woocommerce-gateway-stripe' ), + 'amount_too_small' => __( 'The order total is too low for this payment method', 'woocommerce-gateway-stripe' ), + 'country_code_invalid' => __( 'Invalid country code, please try again with a valid country code', 'woocommerce-gateway-stripe' ), + 'tax_id_invalid' => __( 'Invalid Tax Id, please try again with a valid tax id', 'woocommerce-gateway-stripe' ), + 'invalid_wallet_type' => __( 'Invalid wallet payment type, please try again or use an alternative method.', 'woocommerce-gateway-stripe' ), + 'payment_intent_authentication_failure' => __( 'We are unable to authenticate your payment method. Please choose a different payment method and try again.', 'woocommerce-gateway-stripe' ), ] ); } diff --git a/includes/class-wc-stripe-payment-tokens.php b/includes/class-wc-stripe-payment-tokens.php index 0368de9ee..fb8c8f1be 100644 --- a/includes/class-wc-stripe-payment-tokens.php +++ b/includes/class-wc-stripe-payment-tokens.php @@ -412,9 +412,28 @@ public function woocommerce_payment_token_deleted( $token_id, $token ) { $stripe_customer = new WC_Stripe_Customer( $token->get_user_id() ); try { if ( WC_Stripe_Feature_Flags::is_upe_checkout_enabled() ) { - if ( in_array( $token->get_gateway_id(), self::UPE_REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) { - $stripe_customer->detach_payment_method( $token->get_token() ); + // If it's not reusable payment method, we don't need to perform any additional checks. + if ( ! in_array( $token->get_gateway_id(), self::UPE_REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) { + return; + } + + /** + * 1. Check if it's live mode. + * 2. Check if it's admin. + * 3. Check if it's not production environment. + * When all conditions are met, we don't want to delete the payment method from Stripe. + * This is to avoid detaching the payment method from the live stripe account on non production environments. + */ + $settings = WC_Stripe_Helper::get_stripe_settings(); + if ( + 'no' === $settings['testmode'] && + is_admin() && + 'production' !== wp_get_environment_type() + ) { + return; } + + $stripe_customer->detach_payment_method( $token->get_token() ); } else { if ( 'stripe' === $token->get_gateway_id() || 'stripe_sepa' === $token->get_gateway_id() ) { $stripe_customer->delete_source( $token->get_token() ); diff --git a/includes/class-wc-stripe-webhook-handler.php b/includes/class-wc-stripe-webhook-handler.php index 078b019a9..84e31fd1c 100644 --- a/includes/class-wc-stripe-webhook-handler.php +++ b/includes/class-wc-stripe-webhook-handler.php @@ -892,9 +892,14 @@ public function get_partial_amount_to_charge( $notification ) { return false; } + /** + * Handles the processing of a payment intent webhook. + * + * @param stdClass $notification The webhook notification from Stripe. + */ public function process_payment_intent_success( $notification ) { $intent = $notification->data->object; - $order = WC_Stripe_Helper::get_order_by_intent_id( $intent->id ); + $order = $this->get_order_from_intent( $intent ); if ( ! $order ) { WC_Stripe_Logger::log( 'Could not find order via intent ID: ' . $intent->id ); @@ -1198,6 +1203,43 @@ public function process_webhook( $request_body ) { } } + + /** + * Fetches an order from a payment intent. + * + * @param stdClass $intent The Stripe PaymentIntent object. + * @return WC_Order|false The order object, or false if not found. + */ + private function get_order_from_intent( $intent ) { + // Attempt to get the order from the intent metadata. + if ( isset( $intent->metadata->signature ) ) { + $signature = wc_clean( $intent->metadata->signature ); + $data = explode( ':', $signature ); + + // Verify we received the order ID and signature (hash). + $order = isset( $data[0], $data[1] ) ? wc_get_order( absint( $data[0] ) ) : false; + + if ( $order ) { + $intent_id = WC_Stripe_Helper::get_intent_id_from_order( $order ); + + // Return the order if the intent ID matches. + if ( $intent->id === $intent_id ) { + return $order; + } + + /** + * If the order has no intent ID stored, we may have failed to store it during the initial payment request. + * Confirm that the signature matches the order, otherwise fall back to finding the order via the intent ID. + */ + if ( empty( $intent_id ) && $this->get_order_signature( $order ) === $signature ) { + return $order; + } + } + } + + // Fall back to finding the order via the intent ID. + return WC_Stripe_Helper::get_order_by_intent_id( $intent->id ); + } } new WC_Stripe_Webhook_Handler(); diff --git a/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php b/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php index b294942ae..c6cc78ad0 100644 --- a/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php +++ b/includes/compat/class-wc-stripe-subscriptions-legacy-sepa-token-update.php @@ -70,14 +70,10 @@ public function maybe_update_subscription_legacy_payment_method( $subscription_i public function maybe_update_subscription_source( WC_Subscription $subscription ) { try { $this->set_subscription_updated_payment_method( $subscription ); - - $order_note = __( 'Stripe Gateway: The payment method used for renewals was updated from Sources to PaymentMethods.', 'woocommerce-gateway-stripe' ); + $subscription->add_order_note( __( 'Stripe Gateway: The payment method used for renewals was updated from Sources to PaymentMethods.', 'woocommerce-gateway-stripe' ) ); } catch ( \Exception $e ) { - /* translators: Reason why the subscription payment method wasn't updated */ - $order_note = sprintf( __( 'Stripe Gateway: A Source is used for renewals but could not be updated to PaymentMethods. Reason: %s', 'woocommerce-gateway-stripe' ), $e->getMessage() ); + WC_Stripe_Logger::log( $e->getMessage() ); } - - $subscription->add_order_note( $order_note ); } /** @@ -131,6 +127,11 @@ private function set_subscription_updated_payment_method( WC_Subscription $subsc // Retrieve the source object from the API. $source_object = WC_Stripe_API::get_payment_method( $source_id ); + // Bail out, if the source object isn't expected to be migrated. eg Card sources are not migrated. + if ( isset( $source_object->type ) && 'card' === $source_object->type ) { + throw new \Exception( sprintf( 'Skipping migration of Source for subscription #%d. Source is a card.', $subscription->get_id() ) ); + } + // Bail out if the src_ hasn't been migrated to pm_ yet. if ( ! isset( $source_object->metadata->migrated_payment_method ) ) { throw new \Exception( sprintf( 'The Source has not been migrated to PaymentMethods on the Stripe account.', $subscription->get_id() ) ); diff --git a/includes/compat/trait-wc-stripe-subscriptions.php b/includes/compat/trait-wc-stripe-subscriptions.php index 4811e834b..91bc3bc54 100644 --- a/includes/compat/trait-wc-stripe-subscriptions.php +++ b/includes/compat/trait-wc-stripe-subscriptions.php @@ -682,7 +682,7 @@ public function add_subscription_payment_meta( $payment_meta, $subscription ) { ], '_stripe_source_id' => [ 'value' => $source_id, - 'label' => 'Stripe Source ID', + 'label' => 'Stripe Payment Method ID', ], ], ]; @@ -720,7 +720,7 @@ public function validate_subscription_payment_meta( $payment_method_id, $payment && 0 !== strpos( $payment_meta['post_meta']['_stripe_source_id']['value'], 'pm_' ) ) ) { - throw new Exception( __( 'Invalid source ID. A valid source "Stripe Source ID" must begin with "src_", "pm_", or "card_".', 'woocommerce-gateway-stripe' ) ); + throw new Exception( __( 'Invalid payment method ID. A valid "Stripe Payment Method ID" must begin with "src_", "pm_", or "card_".', 'woocommerce-gateway-stripe' ) ); } } } @@ -965,14 +965,14 @@ public function maybe_render_subscription_payment_method( $payment_method_to_dis // Legacy handling for Stripe Card objects. ref: https://docs.stripe.com/api/cards/object if ( isset( $source->object ) && WC_Stripe_Payment_Methods::CARD === $source->object ) { /* translators: 1) card brand 2) last 4 digits */ - $payment_method_to_display = sprintf( __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), ( isset( $source->brand ) ? $source->brand : __( 'N/A', 'woocommerce-gateway-stripe' ) ), $source->last4 ); + $payment_method_to_display = sprintf( __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), ( isset( $source->brand ) ? wc_get_credit_card_type_label( $source->brand ) : __( 'N/A', 'woocommerce-gateway-stripe' ) ), $source->last4 ); break 2; } switch ( $source->type ) { case WC_Stripe_Payment_Methods::CARD: /* translators: 1) card brand 2) last 4 digits */ - $payment_method_to_display = sprintf( __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), ( isset( $source->card->brand ) ? $source->card->brand : __( 'N/A', 'woocommerce-gateway-stripe' ) ), $source->card->last4 ); + $payment_method_to_display = sprintf( __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), ( isset( $source->card->brand ) ? wc_get_credit_card_type_label( $source->card->brand ) : __( 'N/A', 'woocommerce-gateway-stripe' ) ), $source->card->last4 ); break 3; case WC_Stripe_Payment_Methods::SEPA_DEBIT: /* translators: 1) last 4 digits of SEPA Direct Debit */ diff --git a/includes/connect/class-wc-stripe-connect.php b/includes/connect/class-wc-stripe-connect.php index e1c88d136..74d3e1f39 100644 --- a/includes/connect/class-wc-stripe-connect.php +++ b/includes/connect/class-wc-stripe-connect.php @@ -173,6 +173,9 @@ private function save_stripe_keys( $result, $type = 'connect', $mode = 'live' ) unset( $options['account_id'] ); unset( $options['test_account_id'] ); + // Enable ECE for new connections. + $this->enable_ece_in_new_accounts(); + WC_Stripe_Helper::update_main_stripe_settings( $options ); // Similar to what we do for webhooks, we save some stats to help debug oauth problems. @@ -213,6 +216,17 @@ private function get_upe_checkout_experience_enabled() { return 'yes'; } + /** + * Enable Stripe express checkout element for new connections. + */ + private function enable_ece_in_new_accounts() { + $existing_stripe_settings = WC_Stripe_Helper::get_stripe_settings(); + + if ( empty( $existing_stripe_settings ) ) { + update_option( WC_Stripe_Feature_Flags::ECE_FEATURE_FLAG_NAME, 'yes' ); + } + } + /** * Gets default Stripe settings */ diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php index eb79ef3d8..fae03b94d 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-ajax-handler.php @@ -294,6 +294,7 @@ public function ajax_get_selected_product_data() { wp_send_json( $data ); } catch ( Exception $e ) { + WC_Stripe_Logger::log( 'Product data error in express checkout: ' . $e->getMessage() ); wp_send_json( [ 'error' => wp_strip_all_tags( $e->getMessage() ) ] ); } } @@ -302,23 +303,33 @@ public function ajax_get_selected_product_data() { * Create order. Security is handled by WC. */ public function ajax_create_order() { - if ( WC()->cart->is_empty() ) { - wp_send_json_error( __( 'Empty cart', 'woocommerce-gateway-stripe' ) ); - } + try { + if ( WC()->cart->is_empty() ) { + wp_send_json_error( __( 'Empty cart', 'woocommerce-gateway-stripe' ) ); + } - if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { - define( 'WOOCOMMERCE_CHECKOUT', true ); - } + if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { + define( 'WOOCOMMERCE_CHECKOUT', true ); + } + + $this->express_checkout_helper->fix_address_fields_mapping(); - $this->express_checkout_helper->fix_address_fields_mapping(); + // Normalizes billing and shipping state values. + $this->express_checkout_helper->normalize_state(); - // Normalizes billing and shipping state values. - $this->express_checkout_helper->normalize_state(); + // In case the state is required, but is missing, add a more descriptive error notice. + $this->express_checkout_helper->validate_state(); - // In case the state is required, but is missing, add a more descriptive error notice. - $this->express_checkout_helper->validate_state(); + WC()->checkout()->process_checkout(); + } catch ( Exception $e ) { + WC_Stripe_Logger::log( 'Failed to create order for express checkout payment: ' . $e ); - WC()->checkout()->process_checkout(); + $response = [ + 'result' => 'error', + 'messages' => $e->getMessage(), + ]; + wp_send_json( $response, 400 ); + } die( 0 ); } @@ -356,14 +367,14 @@ public function ajax_pay_for_order() { return; } + $order_id = intval( $_POST['order'] ); try { // Set up an environment, similar to core checkout. wc_maybe_define_constant( 'WOOCOMMERCE_CHECKOUT', true ); wc_set_time_limit( 0 ); // Load the order. - $order_id = intval( $_POST['order'] ); - $order = wc_get_order( $order_id ); + $order = wc_get_order( $order_id ); if ( ! is_a( $order, WC_Order::class ) ) { throw new Exception( __( 'Invalid order!', 'woocommerce-gateway-stripe' ) ); @@ -386,6 +397,8 @@ public function ajax_pay_for_order() { $result = apply_filters( 'woocommerce_payment_successful_result', $result, $order_id ); } catch ( Exception $e ) { + WC_Stripe_Logger::log( 'Pay for order failed for order ' . $order_id . ' with express checkout: ' . $e ); + $result = [ 'result' => 'error', 'messages' => $e->getMessage(), diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-element.php b/includes/payment-methods/class-wc-stripe-express-checkout-element.php index e760b4a90..f9286866d 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-element.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-element.php @@ -91,7 +91,7 @@ public function init() { add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); add_action( 'woocommerce_after_add_to_cart_form', [ $this, 'display_express_checkout_button_html' ], 1 ); - add_action( 'woocommerce_proceed_to_checkout', [ $this, 'display_express_checkout_button_html' ] ); + add_action( 'woocommerce_proceed_to_checkout', [ $this, 'display_express_checkout_button_html' ], 20 ); add_action( 'woocommerce_checkout_before_customer_details', [ $this, 'display_express_checkout_button_html' ], 1 ); add_action( 'woocommerce_pay_order_before_payment', [ $this, 'display_express_checkout_button_html' ], 1 ); @@ -99,6 +99,7 @@ public function init() { add_action( 'woocommerce_checkout_order_processed', [ $this, 'add_order_meta' ], 10, 2 ); add_filter( 'woocommerce_login_redirect', [ $this, 'get_login_redirect_url' ], 10, 3 ); add_filter( 'woocommerce_registration_redirect', [ $this, 'get_login_redirect_url' ], 10, 3 ); + add_filter( 'woocommerce_cart_needs_shipping_address', [ $this, 'filter_cart_needs_shipping_address' ], 11, 1 ); add_action( 'before_woocommerce_pay_form', [ $this, 'localize_pay_for_order_page_scripts' ] ); } @@ -168,21 +169,16 @@ public function get_login_redirect_url( $redirect ) { * @return array The settings used for the Stripe express checkout element in JavaScript. */ public function javascript_params() { - $needs_shipping = 'no'; - if ( ! is_null( WC()->cart ) && WC()->cart->needs_shipping() ) { - $needs_shipping = 'yes'; - } - return [ - 'ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), - 'stripe' => [ + 'ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), + 'stripe' => [ 'publishable_key' => 'yes' === $this->stripe_settings['testmode'] ? $this->stripe_settings['test_publishable_key'] : $this->stripe_settings['publishable_key'], 'allow_prepaid_card' => apply_filters( 'wc_stripe_allow_prepaid_card', true ) ? 'yes' : 'no', 'locale' => WC_Stripe_Helper::convert_wc_locale_to_stripe_locale( get_locale() ), 'is_link_enabled' => WC_Stripe_UPE_Payment_Method_Link::is_link_enabled(), 'is_express_checkout_enabled' => $this->express_checkout_helper->is_express_checkout_enabled(), ], - 'nonce' => [ + 'nonce' => [ 'payment' => wp_create_nonce( 'wc-stripe-express-checkout' ), 'shipping' => wp_create_nonce( 'wc-stripe-express-checkout-shipping' ), 'get_cart_details' => wp_create_nonce( 'wc-stripe-get-cart-details' ), @@ -194,26 +190,21 @@ public function javascript_params() { 'clear_cart' => wp_create_nonce( 'wc-stripe-clear-cart' ), 'pay_for_order' => wp_create_nonce( 'wc-stripe-pay-for-order' ), ], - 'i18n' => [ + 'i18n' => [ 'no_prepaid_card' => __( 'Sorry, we\'re not accepting prepaid cards at this time.', 'woocommerce-gateway-stripe' ), /* translators: Do not translate the [option] placeholder */ 'unknown_shipping' => __( 'Unknown shipping option "[option]".', 'woocommerce-gateway-stripe' ), ], - 'checkout' => [ - 'url' => wc_get_checkout_url(), - 'currency_code' => strtolower( get_woocommerce_currency() ), - 'country_code' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ), - 'needs_shipping' => $needs_shipping, - // Defaults to 'required' to match how core initializes this option. - 'needs_payer_phone' => 'required' === get_option( 'woocommerce_checkout_phone_field', 'required' ), - ], - 'button' => $this->express_checkout_helper->get_button_settings(), - 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), - 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), - 'login_confirmation' => $this->express_checkout_helper->get_login_confirmation_settings(), - 'is_product_page' => $this->express_checkout_helper->is_product(), - 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), - 'product' => $this->express_checkout_helper->get_product_data(), + 'checkout' => $this->express_checkout_helper->get_checkout_data(), + 'button' => $this->express_checkout_helper->get_button_settings(), + 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), + 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), + 'login_confirmation' => $this->express_checkout_helper->get_login_confirmation_settings(), + 'is_product_page' => $this->express_checkout_helper->is_product(), + 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), + 'product' => $this->express_checkout_helper->get_product_data(), + 'is_cart_page' => is_cart(), + 'taxes_based_on_billing' => wc_tax_enabled() && get_option( 'woocommerce_tax_based_on' ) === 'billing', ]; } @@ -413,7 +404,7 @@ public function display_express_checkout_button_html() { * Display express checkout button separator. */ public function display_express_checkout_button_separator_html() { - if ( ! is_checkout() && ! is_cart() && ! is_wc_endpoint_url( 'order-pay' ) ) { + if ( ! is_checkout() && ! is_wc_endpoint_url( 'order-pay' ) ) { return; } @@ -421,12 +412,21 @@ public function display_express_checkout_button_separator_html() { return; } - if ( is_cart() && ! in_array( 'cart', $this->express_checkout_helper->get_button_locations(), true ) ) { - return; - } - ?> express_checkout_helper->has_subscription_product() && wc_get_shipping_method_count( true, true ) === 0 ) { + return false; + } + + return $needs_shipping_address; + } } diff --git a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php index eea5e1426..973e5c985 100644 --- a/includes/payment-methods/class-wc-stripe-express-checkout-helper.php +++ b/includes/payment-methods/class-wc-stripe-express-checkout-helper.php @@ -252,12 +252,7 @@ public function get_product_data() { 'pending' => true, ]; - $data['shippingOptions'] = [ - 'id' => 'pending', - 'label' => __( 'Pending', 'woocommerce-gateway-stripe' ), - 'detail' => '', - 'amount' => 0, - ]; + $data['shippingOptions'] = [ $this->get_default_shipping_option() ]; } $data['displayItems'] = $items; @@ -277,6 +272,45 @@ public function get_product_data() { return apply_filters( 'wc_stripe_payment_request_product_data', $data, $product ); } + /** + * JS params data used by cart and checkout pages. + * + * @param array $data + */ + public function get_checkout_data() { + $data = [ + 'url' => wc_get_checkout_url(), + 'currency_code' => strtolower( get_woocommerce_currency() ), + 'country_code' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ), + 'needs_shipping' => 'no', + 'needs_payer_phone' => 'required' === get_option( 'woocommerce_checkout_phone_field', 'required' ), + 'default_shipping_option' => $this->get_default_shipping_option(), + ]; + + if ( ! is_null( WC()->cart ) && WC()->cart->needs_shipping() ) { + $data['needs_shipping'] = 'yes'; + } + + return $data; + } + + /** + * Default shipping option, used by product, cart and checkout pages. + * + * @return void|array + */ + private function get_default_shipping_option() { + if ( wc_get_shipping_method_count( true, true ) === 0 ) { + return null; + } + + return [ + 'id' => 'pending', + 'displayName' => __( 'Pending', 'woocommerce-gateway-stripe' ), + 'amount' => 0, + ]; + } + /** * Normalizes postal code in case of redacted data from Apple Pay. * @@ -514,6 +548,11 @@ public function should_show_express_checkout_button() { return false; } + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + if ( ! isset( $available_gateways['stripe'] ) ) { + return false; + } + // Don't show if on the cart or checkout page, or if page contains the cart or checkout // shortcodes, with items in the cart that aren't supported. if ( @@ -533,7 +572,7 @@ public function should_show_express_checkout_button() { return false; } - // Don't show if product page PRB is disabled. + // Don't show if product page ECE is disabled. if ( $this->is_product() && ! $this->should_show_ece_on_product_pages() ) { return false; } @@ -543,6 +582,14 @@ public function should_show_express_checkout_button() { return false; } + // Don't show if the total price is 0. + // ToDo: support free trials. Free trials should be supported if the product does not require shipping. + if ( ( ! ( $this->is_pay_for_order_page() || $this->is_product() ) && isset( WC()->cart ) && 0.0 === (float) WC()->cart->get_total( false ) ) + || ( $this->is_product() && 0.0 === (float) $this->get_product()->get_price() ) + ) { + return false; + } + if ( $this->is_product() && in_array( $this->get_product()->get_type(), [ 'variable', 'variable-subscription' ], true ) ) { $stock_availability = array_column( $this->get_product()->get_available_variations(), 'is_in_stock' ); // Don't show if all product variations are out-of-stock. @@ -553,10 +600,12 @@ public function should_show_express_checkout_button() { // Hide if cart/product doesn't require shipping and tax is based on billing or shipping address. if ( + ! $this->is_pay_for_order_page() && ( ( is_product() && ! $this->product_needs_shipping( $this->get_product() ) ) || ( ( is_cart() || is_checkout() ) && ! WC()->cart->needs_shipping() ) ) && + wc_tax_enabled() && in_array( get_option( 'woocommerce_tax_based_on' ), [ 'billing', 'shipping' ], true ) ) { return false; diff --git a/includes/payment-methods/class-wc-stripe-payment-request.php b/includes/payment-methods/class-wc-stripe-payment-request.php index 660e6ea40..7bb83d579 100644 --- a/includes/payment-methods/class-wc-stripe-payment-request.php +++ b/includes/payment-methods/class-wc-stripe-payment-request.php @@ -967,6 +967,11 @@ public function should_show_payment_request_button() { return false; } + $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + if ( ! isset( $available_gateways['stripe'] ) ) { + return false; + } + // Don't show if on the cart or checkout page, or if page contains the cart or checkout // shortcodes, with items in the cart that aren't supported. if ( diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php index 5fcb221d5..92867deaa 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-gateway.php @@ -561,7 +561,7 @@ public function get_upe_available_payment_methods() { */ public function payment_fields() { try { - $display_tokenization = $this->supports( 'tokenization' ) && is_checkout(); + $display_tokenization = $this->supports( 'tokenization' ) && is_checkout() && $this->saved_cards; // Output the form HTML. ?> @@ -587,7 +587,9 @@ public function payment_fields() { tokenization_script(); - $this->saved_payment_methods(); + if ( is_user_logged_in() ) { + $this->saved_payment_methods(); + } } ?> @@ -1609,9 +1611,17 @@ public function set_payment_method_title_for_order( $order, $payment_method_type if ( ! isset( $this->payment_methods[ $payment_method_type ] ) ) { return; } - $payment_method = $this->payment_methods[ $payment_method_type ]; - $payment_method_title = $payment_method->get_title( $stripe_payment_method ); - $payment_method_id = $payment_method instanceof WC_Stripe_UPE_Payment_Method_CC ? $this->id : $payment_method->id; + + $payment_method = $this->payment_methods[ $payment_method_type ]; + $payment_method_id = $payment_method instanceof WC_Stripe_UPE_Payment_Method_CC ? $this->id : $payment_method->id; + $is_stripe_link = isset( $stripe_payment_method->type ) && WC_Stripe_Payment_Methods::LINK === $stripe_payment_method->type; + + // Stripe Link uses the main gateway to process payments, however Link payments should use the title of the Link payment method. + if ( $is_stripe_link && isset( $this->payment_methods[ WC_Stripe_Payment_Methods::LINK ] ) ) { + $payment_method_title = $this->payment_methods[ WC_Stripe_Payment_Methods::LINK ]->get_title( $stripe_payment_method ); + } else { + $payment_method_title = $payment_method->get_title( $stripe_payment_method ); + } $order->set_payment_method( $payment_method_id ); $order->set_payment_method_title( $payment_method_title ); @@ -1740,6 +1750,7 @@ public function get_metadata_from_order( $order ) { 'order_id' => $order->get_order_number(), 'order_key' => $order->get_order_key(), 'payment_type' => $payment_type, + 'signature' => $this->get_order_signature( $order ), ]; return apply_filters( 'wc_stripe_intent_metadata', $metadata, $order ); diff --git a/includes/payment-methods/class-wc-stripe-upe-payment-method-link.php b/includes/payment-methods/class-wc-stripe-upe-payment-method-link.php index c34a52ba5..b1d6721e4 100644 --- a/includes/payment-methods/class-wc-stripe-upe-payment-method-link.php +++ b/includes/payment-methods/class-wc-stripe-upe-payment-method-link.php @@ -32,7 +32,6 @@ public function __construct() { * @return bool */ public static function is_link_enabled() { - // Assume Link is disabled if UPE is disabled. if ( ! WC_Stripe_Feature_Flags::is_upe_checkout_enabled() ) { return false; diff --git a/package-lock.json b/package-lock.json index 38929984e..80a07b112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "woocommerce-gateway-stripe", - "version": "8.8.2", + "version": "8.9.0", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/package.json b/package.json index a98bd1896..3206a09c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "woocommerce-gateway-stripe", "title": "WooCommerce Gateway Stripe", - "version": "8.8.2", + "version": "8.9.0", "license": "GPL-3.0", "homepage": "http://wordpress.org/plugins/woocommerce-gateway-stripe/", "repository": { diff --git a/readme.txt b/readme.txt index 4abd707c8..2c6304340 100644 --- a/readme.txt +++ b/readme.txt @@ -1,10 +1,10 @@ === WooCommerce Stripe Payment Gateway === Contributors: woocommerce, automattic, royho, akeda, mattyza, bor0, woothemes Tags: credit card, stripe, apple pay, payment request, google pay, sepa, bancontact, alipay, giropay, ideal, p24, woocommerce, automattic -Requires at least: 6.4 -Tested up to: 6.6 +Requires at least: 6.5 +Tested up to: 6.7 Requires PHP: 7.4 -Stable tag: 8.8.2 +Stable tag: 8.9.0 License: GPLv3 License URI: https://www.gnu.org/licenses/gpl-3.0.html Attributions: thorsten-stripe @@ -110,9 +110,33 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o == Changelog == -= 8.8.2 - 2024-11-07 = -* Fix - Prevent marking renewal orders as processing/completed multiple times due to handling the Stripe webhook in parallel. -* Dev - Refactor lock_order_payment() to use order meta instead of transients. -* Update - Process successful payment intent webhooks asynchronously. += 8.9.0 - 2024-11-14 = +* Update - Enhance webhook processing to enable retrieving orders using payment_intent metadata. +* Dev - Minor updates to the webhook handler class related to payment method names constants. +* Tweak - Improve error message displayed when payment method creation fails in classic checkout. +* Dev - Replace two occurrences of payment method names with their constant equivalents. +* Fix - Hide express checkout when credit card payments are not enabled. +* Fix - Fix issues when detaching payment methods on staging sites (with the new checkout experience enabled). +* Fix - Display a notice if taxes vary by customer's billing address when checking out using the Stripe Express Checkout Element. +* Tweak - Makes the new Stripe Express Checkout Element enabled by default in new accounts. +* Dev - Add multiple unit tests for the Stripe Express Checkout Element implementation (for both frontend and backend). +* Fix - Check if taxes are enabled when applying ECE tax compatibility check. +* Fix - Fix ECE error when initial address on load is not defined as a shipping zone. +* Fix - Corrected card brand capitalization on the My Account → Subscription page. +* Fix - Displays a specific message when an authentication error occurs during checkout for 3DS cards (shortcode version). +* Fix - Show 'Use a New Payment Method' radio button for logged in users only when card saving is enabled. +* Fix - Fix the display and usage of the Link payment method on the shortcode checkout page with the Stripe Express Checkout Element. +* Fix - Fix payment methods count on settings page. +* Update - Improve Express Payment button previews on the edit Block Checkout and Cart pages for Google Pay and Apple Pay. +* Tweak - Add error logging in ECE critical Ajax requests. +* Add - Add support for Stripe Link payments via the new Stripe Checkout Element on the block cart and block checkout pages. +* Add - Add support for Stripe Link payments via the new Stripe Checkout Element on the product, cart, checkout and pay for order pages. +* Tweak - Do not load ECE button if the total amount is 0. +* Add - Show ECE button preview on settings page. +* Tweak - Remove the subscription order notes added each time a source wasn't migrated. +* Tweak - Update ECE default button type. +* Fix - Fix position of ECE button on shortcode cart page. +* Fix - Call ECE specific 'paymentFailed' function only when payment request fails. +* Fix - Fix issue in purchasing subscriptions when the store has no shipping options. [See changelog for all versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt). diff --git a/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php b/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php index 498248b8e..90475e17b 100644 --- a/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php +++ b/tests/phpunit/admin/migrations/test-class-wc-stripe-subscriptions-repairer-legacy-sepa-tokens.php @@ -328,18 +328,9 @@ function ( $id ) { $this->updater->maybe_migrate_before_renewal( $subscription_id ); $subscription = new WC_Subscription( $subscription_id ); - $notes = wc_get_order_notes( - [ 'order_id' => $subscription_id ] - ); // Confirm the subscription's payment method remains the same. $this->assertEquals( $pm_id, $subscription->get_meta( self::SOURCE_ID_META_KEY ) ); - - // Confirm a note is added when the Source wasn't migrated to PaymentMethods. - $this->assertEquals( - 'Stripe Gateway: A Source is used for renewals but could not be updated to PaymentMethods. Reason: The subscription is not using a Stripe Source for renewals.', - $notes[0]->content - ); } public function test_maybe_update_subscription_legacy_payment_method_adds_note_when_source_not_migrated() { @@ -361,18 +352,9 @@ function ( $id ) { $this->updater->maybe_migrate_before_renewal( $subscription_id ); $subscription = new WC_Subscription( $subscription_id ); - $notes = wc_get_order_notes( - [ 'order_id' => $subscription_id ] - ); // Confirm the subscription's payment method remains the same. $this->assertEquals( $source_id, $subscription->get_meta( self::SOURCE_ID_META_KEY ) ); - - // Confirm a note is added when the Source wasn't migrated to PaymentMethods. - $this->assertEquals( - 'Stripe Gateway: A Source is used for renewals but could not be updated to PaymentMethods. Reason: The Source has not been migrated to PaymentMethods on the Stripe account.', - $notes[0]->content - ); } /** diff --git a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php index 7a33639a1..e3598461d 100644 --- a/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php +++ b/tests/phpunit/test-class-wc-stripe-upe-payment-gateway.php @@ -218,9 +218,10 @@ private function get_order_details( $order ) { 'customer_name' => 'Jeroen Sormani', 'customer_email' => 'admin@example.org', 'site_url' => 'http://example.org', - 'order_id' => $order_id, + 'order_id' => $order_number, 'order_key' => $order_key, 'payment_type' => 'single', + 'signature' => sprintf( '%d:%s', $order->get_id(), md5( implode( '-', [ absint( $order->get_id() ), $order->get_order_key(), $order->get_customer_id(), $amount ] ) ) ), ]; return [ $amount, $description, $metadata ]; } diff --git a/tests/phpunit/test-wc-stripe-express-checkout-element.php b/tests/phpunit/test-wc-stripe-express-checkout-element.php new file mode 100644 index 000000000..2b14f3e8d --- /dev/null +++ b/tests/phpunit/test-wc-stripe-express-checkout-element.php @@ -0,0 +1,365 @@ +getMockBuilder( WC_Stripe_Express_Checkout_Ajax_Handler::class ) + ->disableOriginalConstructor() + ->getMock(); + + $helper = $this->getMockBuilder( WC_Stripe_Express_Checkout_Helper::class ) + ->disableOriginalConstructor() + ->getMock(); + + $this->element = new WC_Stripe_Express_Checkout_Element( $ajax_handler, $helper ); + } + + /** + * Test for `get_login_redirect_url`. + * + * @return void + */ + public function test_get_login_redirect_url() { + $actual = $this->element->get_login_redirect_url( 'http://example.com/redirect' ); + + $this->assertSame( 'http://example.com/redirect', $actual ); + } + + /** + * Test for `javascript_params`. + * + * @return void + */ + public function test_javascript_params() { + $stripe_settings['testmode'] = 'yes'; + $stripe_settings['test_publishable_key'] = 'pk_test_123'; + + WC_Stripe_Helper::update_main_stripe_settings( $stripe_settings ); + + $ajax_handler = $this->getMockBuilder( WC_Stripe_Express_Checkout_Ajax_Handler::class ) + ->disableOriginalConstructor() + ->getMock(); + + $helper = $this->getMockBuilder( WC_Stripe_Express_Checkout_Helper::class ) + ->disableOriginalConstructor() + ->getMock(); + + $element = new WC_Stripe_Express_Checkout_Element( $ajax_handler, $helper ); + + $actual = $element->javascript_params(); + + $this->assertSame( $stripe_settings['test_publishable_key'], $actual['stripe']['publishable_key'] ); + } + + /** + * Test for `scripts`. + * + * @return void + * @dataProvider provide_test_scripts + */ + public function test_scripts( $page_supported, $should_show, $expected ) { + $stripe_settings['testmode'] = 'yes'; + $stripe_settings['test_publishable_key'] = 'pk_test_123'; + + WC_Stripe_Helper::update_main_stripe_settings( $stripe_settings ); + + $ajax_handler = $this->getMockBuilder( WC_Stripe_Express_Checkout_Ajax_Handler::class ) + ->disableOriginalConstructor() + ->getMock(); + + $helper = $this->getMockBuilder( WC_Stripe_Express_Checkout_Helper::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'is_page_supported', 'should_show_express_checkout_button' ] ) + ->getMock(); + + $helper->expects( $this->any() ) + ->method( 'is_page_supported' ) + ->willReturn( $page_supported ); + + $helper->expects( $this->any() ) + ->method( 'should_show_express_checkout_button' ) + ->willReturn( $should_show ); + + $element = new WC_Stripe_Express_Checkout_Element( $ajax_handler, $helper ); + + $element->scripts(); + $actual = wp_script_is( 'wc_stripe_express_checkout', 'enqueued' ); + $this->assertSame( $expected, $actual ); + } + + /** + * Provider for `test_scripts`. + * + * @return string[] + */ + public function provide_test_scripts() { + return [ + 'page not supported' => [ + 'page supported' => false, + 'should show' => false, + 'expected' => false, + ], + 'should not show' => [ + 'page supported' => true, + 'should show' => false, + 'expected' => false, + ], + 'successfully rendered' => [ + 'page supported' => true, + 'should show' => true, + 'expected' => true, + ], + ]; + } + + /** + * Test for `add_order_meta`. + * + * @param string $checkout_type The checkout type. + * @param string $expected The expected payment method title. + * @return void + * @dataProvider provide_test_add_order_meta + */ + public function test_add_order_meta( $checkout_type, $expected ) { + $order = wc_create_order(); + + $_POST['express_checkout_type'] = $checkout_type; + $_POST['payment_method'] = 'stripe'; + + $this->element->add_order_meta( $order->get_id(), [] ); + $order = wc_get_order( $order->get_id() ); + + $this->assertSame( $expected, $order->get_payment_method_title() ); + } + + /** + * Provider for `test_add_order_meta`. + * + * @return array[] + */ + public function provide_test_add_order_meta() { + return [ + 'apple pay' => [ + 'checkout type' => 'apple_pay', + 'expected' => 'Apple Pay (Stripe)', + ], + 'google pay' => [ + 'checkout type' => 'google_pay', + 'expected' => 'Google Pay (Stripe)', + ], + ]; + } + + /** + * Test for `filter_gateway_title`. + * + * @return void + */ + public function test_filter_gateway_title() { + $actual = $this->element->filter_gateway_title( 'test', 'stripe' ); + $this->assertSame( 'test', $actual ); + } + + /** + * Test for `display_express_checkout_button_html`. + * + * @param bool $stripe_is_enabled Whether Stripe is enabled. + * @param bool $page_supported Whether the current page is supported. + * @param bool $should_show Whether the button should be shown. + * @param string $expected The expected output. + * @return void + * @dataProvider provide_test_display_express_checkout_button_html + */ + public function test_display_express_checkout_button_html( $stripe_is_enabled, $page_supported, $should_show, $expected ) { + if ( $stripe_is_enabled ) { + add_filter( + 'woocommerce_available_payment_gateways', + function () use ( $stripe_is_enabled ) { + return [ + 'stripe' => new class() extends WC_Payment_Gateway { + public function __construct() { + $this->id = 'stripe'; + } + }, + ]; + } + ); + } + + $ajax_handler = $this->getMockBuilder( WC_Stripe_Express_Checkout_Ajax_Handler::class ) + ->disableOriginalConstructor() + ->getMock(); + + $helper = $this->getMockBuilder( WC_Stripe_Express_Checkout_Helper::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'is_page_supported', 'should_show_express_checkout_button' ] ) + ->getMock(); + + $helper->expects( $this->any() ) + ->method( 'is_page_supported' ) + ->willReturn( $page_supported ); + + $helper->expects( $this->any() ) + ->method( 'should_show_express_checkout_button' ) + ->willReturn( $should_show ); + + $element = new WC_Stripe_Express_Checkout_Element( $ajax_handler, $helper ); + + ob_start(); + + $element->display_express_checkout_button_html(); + $output = ob_get_clean(); + $this->assertStringMatchesFormat( $expected, $output ); + } + + /** + * Provider for `test_display_express_checkout_button_html`. + * + * @return array + */ + public function provide_test_display_express_checkout_button_html() { + return [ + 'stripe disabled' => [ + 'stripe is enabled' => false, + 'page supported' => false, + 'should show ECE' => false, + 'expected' => '', + ], + 'page not supported' => [ + 'stripe is enabled' => true, + 'page supported' => false, + 'should show ECE' => false, + 'expected' => '', + ], + 'should not show ECE' => [ + 'stripe is enabled' => true, + 'page supported' => true, + 'should show ECE' => false, + 'expected' => '', + ], + 'render successfully' => [ + 'stripe is enabled' => true, + 'page supported' => true, + 'should show ECE' => true, + 'expected' => '%aid="wc-stripe-express-checkout-element"%a', + ], + ]; + } + + /** + * Test for `display_express_checkout_button_separator_html`. + * + * @param bool $is_checkout Whether the current page is checkout. + * @param bool $is_cart Whether the current page is cart. + * @param bool $is_order_pay Whether the current page is order pay. + * @param string $button_location The location of the button. + * @param string $expected The expected output. + * @return void + * @dataProvider provide_test_display_express_checkout_button_separator_html + */ + public function test_display_express_checkout_button_separator_html( $is_checkout, $is_cart, $is_order_pay, $button_location, $expected ) { + add_filter( + 'woocommerce_is_checkout', + function () use ( $is_checkout ) { + return $is_checkout; + } + ); + + if ( $is_cart ) { + \Automattic\Jetpack\Constants::set_constant( 'WOOCOMMERCE_CART', true ); + } else { + \Automattic\Jetpack\Constants::clear_single_constant( 'WOOCOMMERCE_CART' ); + } + + add_filter( + 'woocommerce_get_query_vars', + function () use ( $is_order_pay ) { + if ( ! $is_order_pay ) { + return []; + } + + return [ + 'is_order_pay' => $is_order_pay, + ]; + } + ); + + $ajax_handler = $this->getMockBuilder( WC_Stripe_Express_Checkout_Ajax_Handler::class ) + ->disableOriginalConstructor() + ->getMock(); + + $helper = $this->getMockBuilder( WC_Stripe_Express_Checkout_Helper::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'get_button_locations' ] ) + ->getMock(); + + $helper->expects( $this->any() ) + ->method( 'get_button_locations' ) + ->willReturn( [ $button_location ] ); + + $element = new WC_Stripe_Express_Checkout_Element( $ajax_handler, $helper ); + + ob_start(); + $element->display_express_checkout_button_separator_html(); + $output = ob_get_clean(); + $this->assertStringMatchesFormat( $expected, $output ); + } + + /** + * Provider for `test_display_express_checkout_button_separator_html`. + * + * @return array + */ + public function provide_test_display_express_checkout_button_separator_html() { + return [ + 'not checkout, not cart, not order pay' => [ + 'is checkout' => false, + 'is cart' => false, + 'is order pay' => false, + 'button location' => null, + 'expected' => '', + ], + 'checkout, button not in checkout' => [ + 'is checkout' => true, + 'is cart' => false, + 'is order pay' => false, + 'button location' => 'cart', + 'expected' => '', + ], + 'cart, button not in cart' => [ + 'is checkout' => false, + 'is cart' => true, + 'is order pay' => false, + 'button location' => 'checkout', + 'expected' => '', + ], + 'checkout, button in checkout' => [ + 'is checkout' => true, + 'is cart' => false, + 'is order pay' => false, + 'button location' => 'checkout', + 'expected' => '%aid="wc-stripe-express-checkout-button-separator"%a', + ], + ]; + } +} diff --git a/tests/phpunit/test-wc-stripe-express-checkout-helper.php b/tests/phpunit/test-wc-stripe-express-checkout-helper.php index 87afc7a08..eee0adb36 100644 --- a/tests/phpunit/test-wc-stripe-express-checkout-helper.php +++ b/tests/phpunit/test-wc-stripe-express-checkout-helper.php @@ -3,13 +3,16 @@ /** * These tests make assertions against class WC_Stripe_Express_Checkout_Helper. * - * @package WooCommerce_Stripe/Tests/WC_Stripe_Express_Checkout_Helper + * @package WooCommerce_Stripe/Tests/WC_Stripe_Express_Checkout_Helper_Test */ /** - * WC_Stripe_Express_Checkout_Helper class. + * WC_Stripe_Express_Checkout_Helper_Test class. */ class WC_Stripe_Express_Checkout_Helper_Test extends WP_UnitTestCase { + private $shipping_zone; + private $shipping_method; + public function set_up() { parent::set_up(); @@ -21,10 +24,35 @@ public function set_up() { WC_Stripe_Helper::update_main_stripe_settings( $stripe_settings ); } + public function tear_down() { + if ( $this->shipping_zone ) { + delete_option( $this->shipping_method->get_instance_option_key() ); + $this->shipping_zone->delete(); + } + + parent::tear_down(); + } + + public function set_up_shipping_methods() { + // Add a shipping zone. + $this->shipping_zone = new WC_Shipping_Zone(); + $this->shipping_zone->set_zone_name( 'Worldwide' ); + $this->shipping_zone->set_zone_order( 1 ); + $this->shipping_zone->save(); + + $flat_rate_id = $this->shipping_zone->add_shipping_method( 'flat_rate' ); + $this->shipping_method = WC_Shipping_Zones::get_shipping_method( $flat_rate_id ); + $option_key = $this->shipping_method->get_instance_option_key(); + $options['cost'] = '5'; + update_option( $option_key, $options ); + } + /** * Test should_show_express_checkout_button, tax logic. */ public function test_hides_ece_if_cannot_compute_taxes() { + $this->set_up_shipping_methods(); + $wc_stripe_ece_helper_mock = $this->createPartialMock( WC_Stripe_Express_Checkout_Helper::class, [ @@ -32,16 +60,22 @@ public function test_hides_ece_if_cannot_compute_taxes() { 'allowed_items_in_cart', 'should_show_ece_on_cart_page', 'should_show_ece_on_checkout_page', + 'is_pay_for_order_page', ] ); $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'is_product' )->willReturn( false ); $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'allowed_items_in_cart' )->willReturn( true ); $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'should_show_ece_on_cart_page' )->willReturn( true ); $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'should_show_ece_on_checkout_page' )->willReturn( true ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'is_pay_for_order_page' )->willReturnOnConsecutiveCalls( true, false ); $wc_stripe_ece_helper_mock->testmode = true; if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { define( 'WOOCOMMERCE_CHECKOUT', true ); } + $original_gateways = WC()->payment_gateways()->payment_gateways; + WC()->payment_gateways()->payment_gateways = [ + 'stripe' => new WC_Gateway_Stripe(), + ]; // Create virtual product and add to cart. $virtual_product = WC_Helper_Product::create_simple_product(); @@ -51,20 +85,111 @@ public function test_hides_ece_if_cannot_compute_taxes() { WC()->session->init(); WC()->cart->add_to_cart( $virtual_product->get_id(), 1 ); + // Do not hide if Pay for Order page. + update_option( 'woocommerce_tax_based_on', 'shipping' ); + $this->assertTrue( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + // Hide if cart has virtual product and tax is based on shipping or billing address. + update_option( 'woocommerce_calc_taxes', 'yes' ); update_option( 'woocommerce_tax_based_on', 'billing' ); $this->assertFalse( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); update_option( 'woocommerce_tax_based_on', 'shipping' ); $this->assertFalse( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + // Do not hide if taxes are not enabled. + update_option( 'woocommerce_calc_taxes', 'no' ); + $this->assertTrue( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + // Do not hide if taxes are not based on customer billing or shipping address. + update_option( 'woocommerce_calc_taxes', 'yes' ); update_option( 'woocommerce_tax_based_on', 'base' ); $this->assertTrue( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); // Do not hide if cart requires shipping. + update_option( 'woocommerce_tax_based_on', 'billing' ); $shippable_product = WC_Helper_Product::create_simple_product(); WC()->cart->add_to_cart( $shippable_product->get_id(), 1 ); $this->assertTrue( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + + // Restore original gateways. + WC()->payment_gateways()->payment_gateways = $original_gateways; + } + + /** + * Test should_show_express_checkout_button, gateway logic. + */ + public function test_hides_ece_if_stripe_gateway_unavailable() { + $this->set_up_shipping_methods(); + + $wc_stripe_ece_helper_mock = $this->createPartialMock( + WC_Stripe_Express_Checkout_Helper::class, + [ + 'is_product', + 'allowed_items_in_cart', + 'should_show_ece_on_cart_page', + 'should_show_ece_on_checkout_page', + ] + ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'is_product' )->willReturn( false ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'allowed_items_in_cart' )->willReturn( true ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'should_show_ece_on_cart_page' )->willReturn( true ); + $wc_stripe_ece_helper_mock->expects( $this->any() )->method( 'should_show_ece_on_checkout_page' )->willReturn( true ); + $wc_stripe_ece_helper_mock->testmode = true; + if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { + define( 'WOOCOMMERCE_CHECKOUT', true ); + } + $original_gateways = WC()->payment_gateways()->payment_gateways; + + // Hide if 'stripe' gateway is unavailable. + update_option( 'woocommerce_calc_taxes', 'no' ); + WC()->payment_gateways()->payment_gateways = [ + 'stripe' => new WC_Gateway_Stripe(), + 'stripe_alipay' => new WC_Gateway_Stripe_Alipay(), + ]; + $this->assertTrue( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + + unset( WC()->payment_gateways()->payment_gateways['stripe'] ); + $this->assertFalse( $wc_stripe_ece_helper_mock->should_show_express_checkout_button() ); + + // Restore original gateways. + WC()->payment_gateways()->payment_gateways = $original_gateways; + } + + /** + * Test for get_checkout_data(). + */ + public function test_get_checkout_data() { + // Local setup + update_option( 'woocommerce_checkout_phone_field', 'optional' ); + update_option( 'woocommerce_default_country', 'US' ); + update_option( 'woocommerce_currency', 'USD' ); + WC()->cart->empty_cart(); + + $this->set_up_shipping_methods(); + + $wc_stripe_ece_helper = new WC_Stripe_Express_Checkout_Helper(); + $checkout_data = $wc_stripe_ece_helper->get_checkout_data(); + + $this->assertNotEmpty( $checkout_data['url'] ); + $this->assertEquals( 'usd', $checkout_data['currency_code'] ); + $this->assertEquals( 'US', $checkout_data['country_code'] ); + $this->assertEquals( 'no', $checkout_data['needs_shipping'] ); + $this->assertFalse( $checkout_data['needs_payer_phone'] ); + $this->assertArrayHasKey( 'id', $checkout_data['default_shipping_option'] ); + $this->assertArrayHasKey( 'displayName', $checkout_data['default_shipping_option'] ); + $this->assertArrayHasKey( 'amount', $checkout_data['default_shipping_option'] ); + } + + /** + * Test for get_checkout_data(), no shipping zones. + * + * This is in a separate test, to avoid problems with cached data. + */ + public function test_get_checkout_data_no_shipping_zones() { + // When no shipping zones are set up, the default shipping option should be empty. + $wc_stripe_ece_helper = new WC_Stripe_Express_Checkout_Helper(); + $checkout_data = $wc_stripe_ece_helper->get_checkout_data(); + $this->assertEmpty( $checkout_data['default_shipping_option'] ); } } diff --git a/tests/phpunit/test-wc-stripe-payment-gateway.php b/tests/phpunit/test-wc-stripe-payment-gateway.php index b37e28337..86f4be565 100644 --- a/tests/phpunit/test-wc-stripe-payment-gateway.php +++ b/tests/phpunit/test-wc-stripe-payment-gateway.php @@ -569,6 +569,63 @@ public function test_get_balance_transaction_id_from_charge() { $this->assertEquals( null, $this->gateway->get_balance_transaction_id_from_charge( null ) ); } + /** + * Tests for Card brand and last 4 digits are displayed correctly for subscription. + * + * @see WC_Stripe_Subscriptions_Trait::maybe_render_subscription_payment_method() + */ + public function test_render_subscription_payment_method() { + $mock_subscription = WC_Helper_Order::create_order(); // We can use an order as a subscription. + $mock_subscription->set_payment_method( 'stripe' ); + + $mock_subscription->update_meta_data( '_stripe_source_id', 'src_mock' ); + $mock_subscription->update_meta_data( '_stripe_customer_id', 'cus_mock' ); + $mock_subscription->save(); + + // This is the key the customer's payment methods are stored under in the transient. + $transient_key = WC_Stripe_Customer::PAYMENT_METHODS_TRANSIENT_KEY . 'cardcus_mock'; + + $mock_payment_method = new stdClass(); + $mock_payment_method->id = 'src_mock'; + $mock_payment_method->type = 'card'; + $mock_payment_method->card = new stdClass(); + + // VISA ending in 4242 + $mock_payment_method->card->brand = 'visa'; + $mock_payment_method->card->last4 = '4242'; + + set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); + $this->assertEquals( 'Via Visa card ending in 4242', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + + // MasterCard ending in 1234 + $mock_payment_method->card->brand = 'mastercard'; + $mock_payment_method->card->last4 = '1234'; + + set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); + $this->assertEquals( 'Via MasterCard card ending in 1234', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + + // American Express ending in 5678 + $mock_payment_method->card->brand = 'amex'; + $mock_payment_method->card->last4 = '5678'; + + set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); + $this->assertEquals( 'Via Amex card ending in 5678', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + + // JCB ending in 9012' + $mock_payment_method->card->brand = 'jcb'; + $mock_payment_method->card->last4 = '9012'; + + set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); + + // Unknown card type + $mock_payment_method->card->brand = 'dummy'; + $mock_payment_method->card->last4 = '0000'; + + set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); + // Card brands that WC core doesn't recognize will be displayed as ucwords. + $this->assertEquals( 'Via Dummy card ending in 0000', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + } + /** * Tests for `lock_order_payment` method. */ diff --git a/uninstall.php b/uninstall.php index 49409e678..9340d5b0c 100644 --- a/uninstall.php +++ b/uninstall.php @@ -117,4 +117,5 @@ // Feature flags delete_option( '_wcstripe_feature_upe' ); delete_option( 'upe_checkout_experience_accepted_payments' ); + delete_option( '_wcstripe_feature_ece' ); } diff --git a/woocommerce-gateway-stripe.php b/woocommerce-gateway-stripe.php index f01d45689..8b7ce3660 100644 --- a/woocommerce-gateway-stripe.php +++ b/woocommerce-gateway-stripe.php @@ -5,12 +5,12 @@ * Description: Take credit card payments on your store using Stripe. * Author: Stripe * Author URI: https://stripe.com/ - * Version: 8.8.2 + * Version: 8.9.0 * Requires Plugins: woocommerce - * Requires at least: 6.4 - * Tested up to: 6.6 - * WC requires at least: 8.9 - * WC tested up to: 9.3 + * Requires at least: 6.5 + * Tested up to: 6.7 + * WC requires at least: 9.2 + * WC tested up to: 9.4 * Text Domain: woocommerce-gateway-stripe * Domain Path: /languages */ @@ -22,7 +22,7 @@ /** * Required minimums and constants */ -define( 'WC_STRIPE_VERSION', '8.8.2' ); // WRCS: DEFINED_VERSION. +define( 'WC_STRIPE_VERSION', '8.9.0' ); // WRCS: DEFINED_VERSION. define( 'WC_STRIPE_MIN_PHP_VER', '7.3.0' ); define( 'WC_STRIPE_MIN_WC_VER', '7.4' ); define( 'WC_STRIPE_FUTURE_MIN_WC_VER', '7.5' ); @@ -262,10 +262,10 @@ public function init() { require_once dirname( __FILE__ ) . '/includes/class-wc-stripe-account.php'; new Allowed_Payment_Request_Button_Types_Update(); - $this->api = new WC_Stripe_Connect_API(); - $this->connect = new WC_Stripe_Connect( $this->api ); - $this->payment_request_configuration = new WC_Stripe_Payment_Request(); - $this->account = new WC_Stripe_Account( $this->connect, 'WC_Stripe_API' ); + $this->api = new WC_Stripe_Connect_API(); + $this->connect = new WC_Stripe_Connect( $this->api ); + $this->payment_request_configuration = new WC_Stripe_Payment_Request(); + $this->account = new WC_Stripe_Account( $this->connect, 'WC_Stripe_API' ); // Express checkout configurations. $express_checkout_helper = new WC_Stripe_Express_Checkout_Helper(); @@ -379,8 +379,8 @@ public function install() { public function update_prb_location_settings() { $stripe_settings = WC_Stripe_Helper::get_stripe_settings(); $prb_locations = isset( $stripe_settings['payment_request_button_locations'] ) - ? $stripe_settings['payment_request_button_locations'] - : []; + ? $stripe_settings['payment_request_button_locations'] + : []; if ( ! empty( $stripe_settings ) && empty( $prb_locations ) ) { global $post; @@ -483,6 +483,16 @@ public function add_gateways( $methods ) { } } + // Don't include Link as an enabled method if we're in the admin so it doesn't show up in the checkout editor page. + if ( is_admin() ) { + $methods = array_filter( + $methods, + function( $method ) { + return ! is_a( $method, WC_Stripe_UPE_Payment_Method_Link::class ); + } + ); + } + return $methods; }