From b2de60bedd27f087beeb50225b2a37bc7b9162fc Mon Sep 17 00:00:00 2001 From: Chris Pappas Date: Fri, 5 Apr 2024 13:30:40 -0400 Subject: [PATCH 01/25] docs: updating readme (#1) * docs: updating readme * chore: changing text to trigger GHA * fix: update CODEOWNERS to be internal revenue-squad --- CODEOWNERS | 4 ++-- README.rst | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b4977b203..e8d685f11 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,3 +1,3 @@ # PCI compliance requires stricter scrutiny, see PR template -/src @openedx/revenue-squad -CODEOWNERS @openedx/revenue-squad +/src @edx/revenue-squad +CODEOWNERS @edx/revenue-squad diff --git a/README.rst b/README.rst index 97cd4cc67..7f940122a 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,9 @@ frontend-app-payment ==================== -Please tag **@edx/revenue-squad** on any PRs or issues. Thanks. +Please tag **@edx/revenue-squad** on any PRs or issues. Thanks! + +This repo is intended for the use of 2U employees. Introduction ------------ From bdb23583d882d79b5d174c71979baaae5dfc3e0f Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 9 Feb 2024 11:25:24 -0500 Subject: [PATCH 02/25] refactor: Add shipping address for Afterpay, PaymentIntentUnexpectedStateError, WIP --- .../FormattedAlertList.jsx | 4 +++ src/feedback/data/sagas.js | 2 +- src/payment/AlertCodeMessages.jsx | 9 +++++++ src/payment/data/handleRequestError.js | 3 +++ src/payment/payment-methods/stripe/service.js | 27 +++++++++++++++++-- 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/components/formatted-alert-list/FormattedAlertList.jsx b/src/components/formatted-alert-list/FormattedAlertList.jsx index 2a483b162..b83b7aeeb 100644 --- a/src/components/formatted-alert-list/FormattedAlertList.jsx +++ b/src/components/formatted-alert-list/FormattedAlertList.jsx @@ -8,6 +8,7 @@ import { EnrollmentCodeQuantityUpdated, TransactionDeclined, BasketChangedError, + PaymentIntentUnexpectedStateError, CaptureKeyTimeoutTwoMinutes, CaptureKeyTimeoutOneMinute, } from '../../payment/AlertCodeMessages'; @@ -47,6 +48,9 @@ export const FormattedAlertList = (props) => { 'basket-changed-error-message': ( ), + 'payment-intent-unexpected-state': ( + + ), 'capture-key-2mins-message': ( ), diff --git a/src/feedback/data/sagas.js b/src/feedback/data/sagas.js index 6d26ce835..ee2b9f45c 100644 --- a/src/feedback/data/sagas.js +++ b/src/feedback/data/sagas.js @@ -19,7 +19,7 @@ export function* handleErrors(e, clearExistingMessages) { if (e.errors !== undefined) { for (let i = 0; i < e.errors.length; i++) { // eslint-disable-line no-plusplus const error = e.errors[i]; - if (error.code === 'basket-changed-error-message') { + if (error.code === 'basket-changed-error-message' || error.code === 'payment-intent-unexpected-state') { yield put(addMessage(error.code, error.userMessage, {}, MESSAGE_TYPES.ERROR)); } else if (error.data === undefined && error.messageType === null) { yield put(addMessage('transaction-declined-message', error.userMessage, {}, MESSAGE_TYPES.ERROR)); diff --git a/src/payment/AlertCodeMessages.jsx b/src/payment/AlertCodeMessages.jsx index 87c64b45c..ba6e686fc 100644 --- a/src/payment/AlertCodeMessages.jsx +++ b/src/payment/AlertCodeMessages.jsx @@ -73,6 +73,15 @@ export const TransactionDeclined = () => ( /> ); +export const PaymentIntentUnexpectedStateError = () => ( + // TEMP TODO: temp copy, not approved by Product/UX yet + +); + export const BasketChangedError = () => ( { - setLocation(response.data.receipt_page_url); + .then(async response => { + if (response.data.receipt_page_url) { + setLocation(response.data.receipt_page_url); + } else { + const { paymentIntent } = await stripe.retrievePaymentIntent(response.data.confirmation_client_secret); + if (paymentIntent && paymentIntent.status !== 'succeeded') { + // TEMP TODO: Payment Intent hasn't been successfully confirmed yet (aka no receipt_page_url) + } + } }) .catch(error => { const errorData = error.response ? error.response.data : null; From 3f144a9759474605c41291a195cfe78c423c96f7 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 4 Mar 2024 10:25:04 -0500 Subject: [PATCH 03/25] feat: Add DynamicPaymentMethodsNotCompatibleError to handle BNPL DPM on unsupported countries --- .../formatted-alert-list/FormattedAlertList.jsx | 4 ++++ src/feedback/data/sagas.js | 2 +- src/payment/AlertCodeMessages.jsx | 9 +++++++++ src/payment/data/handleRequestError.js | 15 +++++++++++++-- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/formatted-alert-list/FormattedAlertList.jsx b/src/components/formatted-alert-list/FormattedAlertList.jsx index b83b7aeeb..f66829ccb 100644 --- a/src/components/formatted-alert-list/FormattedAlertList.jsx +++ b/src/components/formatted-alert-list/FormattedAlertList.jsx @@ -9,6 +9,7 @@ import { TransactionDeclined, BasketChangedError, PaymentIntentUnexpectedStateError, + DynamicPaymentMethodsNotCompatibleError, CaptureKeyTimeoutTwoMinutes, CaptureKeyTimeoutOneMinute, } from '../../payment/AlertCodeMessages'; @@ -51,6 +52,9 @@ export const FormattedAlertList = (props) => { 'payment-intent-unexpected-state': ( ), + 'dynamic-payment-methods-country-not-compatible': ( + + ), 'capture-key-2mins-message': ( ), diff --git a/src/feedback/data/sagas.js b/src/feedback/data/sagas.js index ee2b9f45c..3dfb5d5b4 100644 --- a/src/feedback/data/sagas.js +++ b/src/feedback/data/sagas.js @@ -19,7 +19,7 @@ export function* handleErrors(e, clearExistingMessages) { if (e.errors !== undefined) { for (let i = 0; i < e.errors.length; i++) { // eslint-disable-line no-plusplus const error = e.errors[i]; - if (error.code === 'basket-changed-error-message' || error.code === 'payment-intent-unexpected-state') { + if (error.code === 'basket-changed-error-message' || error.code === 'payment-intent-unexpected-state' || error.code === 'dynamic-payment-methods-country-not-compatible') { yield put(addMessage(error.code, error.userMessage, {}, MESSAGE_TYPES.ERROR)); } else if (error.data === undefined && error.messageType === null) { yield put(addMessage('transaction-declined-message', error.userMessage, {}, MESSAGE_TYPES.ERROR)); diff --git a/src/payment/AlertCodeMessages.jsx b/src/payment/AlertCodeMessages.jsx index ba6e686fc..e42afee02 100644 --- a/src/payment/AlertCodeMessages.jsx +++ b/src/payment/AlertCodeMessages.jsx @@ -82,6 +82,15 @@ export const PaymentIntentUnexpectedStateError = () => ( /> ); +export const DynamicPaymentMethodsNotCompatibleError = () => ( + // TEMP TODO: temp copy, not approved by Product/UX yet + +); + export const BasketChangedError = () => ( Date: Mon, 4 Mar 2024 10:32:24 -0500 Subject: [PATCH 04/25] refactor: Postal code required for BNPL DPM supported countries since API expects it --- .../payment-form/CardHolderInformation.jsx | 6 ++- .../payment-form/StripePaymentForm.jsx | 3 +- .../payment-form/utils/form-validators.js | 38 ++++++++++++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/payment/checkout/payment-form/CardHolderInformation.jsx b/src/payment/checkout/payment-form/CardHolderInformation.jsx index fb09782d3..7dbae32d0 100644 --- a/src/payment/checkout/payment-form/CardHolderInformation.jsx +++ b/src/payment/checkout/payment-form/CardHolderInformation.jsx @@ -59,8 +59,8 @@ export class CardHolderInformationComponent extends React.Component { } render() { - const { disabled, showBulkEnrollmentFields } = this.props; - const shouldRequirePostalCode = isPostalCodeRequired(this.state.selectedCountry) + const { disabled, showBulkEnrollmentFields, isDynamicPaymentMethodsEnabled } = this.props; + const shouldRequirePostalCode = isPostalCodeRequired(this.state.selectedCountry, isDynamicPaymentMethodsEnabled) && this.props.enableStripePaymentProcessor; return ( @@ -277,12 +277,14 @@ CardHolderInformationComponent.propTypes = { intl: intlShape.isRequired, disabled: PropTypes.bool, enableStripePaymentProcessor: PropTypes.bool, + isDynamicPaymentMethodsEnabled: PropTypes.bool, showBulkEnrollmentFields: PropTypes.bool, }; CardHolderInformationComponent.defaultProps = { disabled: false, enableStripePaymentProcessor: false, + isDynamicPaymentMethodsEnabled: false, showBulkEnrollmentFields: false, }; diff --git a/src/payment/checkout/payment-form/StripePaymentForm.jsx b/src/payment/checkout/payment-form/StripePaymentForm.jsx index b89a8042c..bdafccc05 100644 --- a/src/payment/checkout/payment-form/StripePaymentForm.jsx +++ b/src/payment/checkout/payment-form/StripePaymentForm.jsx @@ -53,7 +53,7 @@ const StripePaymentForm = ({ const checkoutDetails = useSelector(paymentDataSelector); const { - enableStripePaymentProcessor, loading, submitting, products, + enableStripePaymentProcessor, loading, submitting, products, isDynamicPaymentMethodsEnabled, } = checkoutDetails; // Loading button should appear when: basket and stripe elements are loading, quantity is updating and not submitting @@ -162,6 +162,7 @@ const StripePaymentForm = ({ showBulkEnrollmentFields={isBulkOrder} disabled={submitting} enableStripePaymentProcessor={enableStripePaymentProcessor} + isDynamicPaymentMethodsEnabled={isDynamicPaymentMethodsEnabled} />
{ }; // eslint-disable-next-line import/prefer-default-export -export function isPostalCodeRequired(selectedCountry) { - const countryListRequiredPostalCode = ['CA', 'GB', 'US']; +export function isPostalCodeRequired(selectedCountry, isDynamicPaymentMethodsEnabled) { + // Stripe recommends to have state and zip code since it can have a material effect on + // our card authorization rates and fees that the card networks and issuers charge. + // 'CA', 'GB' and 'US' were alreay required prior to implementing Dynamic Payment Methods. + // The Stripe API also requires state and zip code for BNPL options (Affirm, Afterpay, Klarna) + // for the countries that these payment methods are compatible with. + let countryListRequiredPostalCode = []; + if (isDynamicPaymentMethodsEnabled) { + countryListRequiredPostalCode = [ + 'CA', // Affirm, Afterpay, Klarna + 'GB', // Afterpay, Klarna + 'US', // Affirm, Afterpay, Klarna + 'AU', // Afterpay, Klarna + 'AT', // Klarna + 'BE', // Klarna + 'CH', // Klarna + 'CZ', // Klarna + 'DE', // Klarna + 'DK', // Klarna + 'ES', // Klarna + 'FI', // Klarna + 'FR', // Klarna + 'GR', // Klarna + 'IE', // Klarna + 'IT', // Klarna + 'NL', // Klarna + 'NO', // Klarna + 'NZ', // Afterpay, Klarna + 'PL', // Klarna + 'PT', // Klarna + 'SE', // Klarna + ]; + } else { + countryListRequiredPostalCode = ['CA', 'GB', 'US']; + } + const postalCodeRequired = countryListRequiredPostalCode.includes(selectedCountry); return postalCodeRequired; From 452966259e208af828a553112e940215d551600c Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 4 Mar 2024 10:35:46 -0500 Subject: [PATCH 05/25] fix: Update stripe checkout function --- src/payment/payment-methods/stripe/service.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/payment/payment-methods/stripe/service.js b/src/payment/payment-methods/stripe/service.js index 037498686..c7231446f 100644 --- a/src/payment/payment-methods/stripe/service.js +++ b/src/payment/payment-methods/stripe/service.js @@ -71,7 +71,7 @@ export default async function checkout( }, }); - if (result.error?.code === 'payment_intent_unexpected_state' && result.error?.type === 'invalid_request_error') { + if (result.error) { handleApiError(result.error); } @@ -79,10 +79,7 @@ export default async function checkout( const postData = formurlencoded({ payment_intent_id: result.paymentIntent.id, skus, - // TEMP TODO: hardcoded true temporarily, until we decide if/what logic we want to have here. - // Stripe A/B Tool doesn't have an experiment attribute to signal it's DPM or an experiment. - // Can look the presence of more than just 'card' in payment_method_types - dynamic_payment_methods_enabled: true, + dynamic_payment_methods_enabled: basket.isDynamicPaymentMethodsEnabled, }); await getAuthenticatedHttpClient() .post( @@ -95,11 +92,6 @@ export default async function checkout( .then(async response => { if (response.data.receipt_page_url) { setLocation(response.data.receipt_page_url); - } else { - const { paymentIntent } = await stripe.retrievePaymentIntent(response.data.confirmation_client_secret); - if (paymentIntent && paymentIntent.status !== 'succeeded') { - // TEMP TODO: Payment Intent hasn't been successfully confirmed yet (aka no receipt_page_url) - } } }) .catch(error => { From a8dddd21a267d57d851f38e0c04c85ecaacb8ceb Mon Sep 17 00:00:00 2001 From: julianajlk Date: Thu, 21 Mar 2024 16:33:38 -0400 Subject: [PATCH 06/25] refactor: Get paymentMethodType onChange and send shipping address only if afterpay --- .../payment-form/StripePaymentForm.jsx | 12 +++++++- src/payment/payment-methods/stripe/service.js | 29 +++++++++++-------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/payment/checkout/payment-form/StripePaymentForm.jsx b/src/payment/checkout/payment-form/StripePaymentForm.jsx index bdafccc05..f8bc8d25c 100644 --- a/src/payment/checkout/payment-form/StripePaymentForm.jsx +++ b/src/payment/checkout/payment-form/StripePaymentForm.jsx @@ -51,6 +51,9 @@ const StripePaymentForm = ({ const [firstErrorId, setfirstErrorId] = useState(false); const [shouldFocusFirstError, setshouldFocusFirstError] = useState(false); + // Check payment type before submitting since BNPL requires state/zip code and Afterpay requires a shipping address + let stripePaymentMethodType; + const checkoutDetails = useSelector(paymentDataSelector); const { enableStripePaymentProcessor, loading, submitting, products, isDynamicPaymentMethodsEnabled, @@ -127,10 +130,16 @@ const StripePaymentForm = ({ } onSubmitPayment({ - skus, elements, stripe, context, values, + skus, elements, stripe, context, values, stripePaymentMethodType, }); }; + const handleStripeElementOnChange = (event) => { + if (event.value) { + stripePaymentMethodType = event.value.type; + } + }; + const stripeElementsOnReady = () => { setIsStripeElementLoading(false); markPerformanceIfAble('Stripe Elements component rendered'); @@ -175,6 +184,7 @@ const StripePaymentForm = ({ id="payment-element" options={options} onReady={stripeElementsOnReady} + onChange={handleStripeElementOnChange} /> {isSubscription ? ( <> diff --git a/src/payment/payment-methods/stripe/service.js b/src/payment/payment-methods/stripe/service.js index c7231446f..13ea42550 100644 --- a/src/payment/payment-methods/stripe/service.js +++ b/src/payment/payment-methods/stripe/service.js @@ -18,7 +18,7 @@ ensureConfig(['ECOMMERCE_BASE_URL', 'STRIPE_RESPONSE_URL'], 'Stripe API service' export default async function checkout( basket, { - skus, elements, stripe, context, values, + skus, elements, stripe, context, values, stripePaymentMethodType, }, setLocation = href => { global.location.href = href; }, // HACK: allow tests to mock setting location ) { @@ -35,6 +35,21 @@ export default async function checkout( purchasedForOrganization, } = values; + let shippingAddress; + if (stripePaymentMethodType === 'afterpay_clearpay') { + shippingAddress = { + address: { + city, + country, + line1: address, + line2: unit || '', + postal_code: postalCode || '', + state: state || '', + }, + name: `${firstName} ${lastName}`, + }; + } + const result = await stripe.updatePaymentIntent({ elements, params: { @@ -57,17 +72,7 @@ export default async function checkout( }, }, // Shipping is required for processing Afterpay payments - shipping: { - address: { - city, - country, - line1: address, - line2: unit || '', - postal_code: postalCode || '', - state: state || '', - }, - name: `${firstName} ${lastName}`, - }, + shipping: shippingAddress, }, }); From 7d2222ce8784c6edd549873d5ee2547709fda995 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Thu, 21 Mar 2024 16:46:07 -0400 Subject: [PATCH 07/25] feat: Add PaymentMethodMessagingElement, modify currencySelector, use prod-edx-cf-loc --- .env | 2 ++ .env.development | 2 ++ .env.development-stage | 2 ++ .env.test | 2 ++ .../payment-form/StripePaymentForm.jsx | 29 ++++++++++++++++++- src/payment/data/redux.test.js | 5 ++++ src/payment/data/utils.js | 3 ++ 7 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.env b/.env index 9b15b303f..f9766565d 100644 --- a/.env +++ b/.env @@ -21,6 +21,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT=null SEGMENT_KEY=null SITE_NAME=null USER_INFO_COOKIE_NAME=null +USER_LOCATION_COOKIE_NAME=null +LOCATION_OVERRIDE_COOKIE=null CURRENCY_COOKIE_NAME=null SUPPORT_URL=null CYBERSOURCE_URL=null diff --git a/.env.development b/.env.development index 3ed3f90cb..a239c4371 100644 --- a/.env.development +++ b/.env.development @@ -19,6 +19,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' SEGMENT_KEY='VMsX2obE9Xveo4se3c6CmfdG0LZVc7qI' SITE_NAME='edX' USER_INFO_COOKIE_NAME='edx-user-info' +USER_LOCATION_COOKIE_NAME='prod-edx-cf-loc' +LOCATION_OVERRIDE_COOKIE='location-override' CURRENCY_COOKIE_NAME='edx-price-l10n' SUPPORT_URL='http://localhost:18000/support' CYBERSOURCE_URL='https://testsecureacceptance.cybersource.com/silent/pay' diff --git a/.env.development-stage b/.env.development-stage index fc6c6f0ed..4e600db6a 100644 --- a/.env.development-stage +++ b/.env.development-stage @@ -19,6 +19,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='/proxy/lms/login_refresh' SEGMENT_KEY=null SITE_NAME='edX' USER_INFO_COOKIE_NAME='npmstage-edx-user-info' +USER_LOCATION_COOKIE_NAME='prod-edx-cf-loc' +LOCATION_OVERRIDE_COOKIE='location-override' CURRENCY_COOKIE_NAME='edx-price-l10n' SUPPORT_URL='/proxy/lms/support' CYBERSOURCE_URL='https://testsecureacceptance.cybersource.com/silent/pay' diff --git a/.env.test b/.env.test index 529f66f27..eba51b57f 100644 --- a/.env.test +++ b/.env.test @@ -17,6 +17,8 @@ REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh' SEGMENT_KEY=null SITE_NAME='edX' USER_INFO_COOKIE_NAME='edx-user-info' +USER_LOCATION_COOKIE_NAME='prod-edx-cf-loc' +LOCATION_OVERRIDE_COOKIE='location-override' # App specific CURRENCY_COOKIE_NAME='edx-price-l10n' diff --git a/src/payment/checkout/payment-form/StripePaymentForm.jsx b/src/payment/checkout/payment-form/StripePaymentForm.jsx index f8bc8d25c..24b320673 100644 --- a/src/payment/checkout/payment-form/StripePaymentForm.jsx +++ b/src/payment/checkout/payment-form/StripePaymentForm.jsx @@ -4,12 +4,15 @@ import React, { import { useSelector } from 'react-redux'; import { reduxForm, SubmissionError } from 'redux-form'; import PropTypes from 'prop-types'; +import Cookies from 'universal-cookie'; import { PaymentElement, + PaymentMethodMessagingElement, useStripe, useElements, } from '@stripe/react-stripe-js'; +import { getConfig } from '@edx/frontend-platform'; import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -56,9 +59,24 @@ const StripePaymentForm = ({ const checkoutDetails = useSelector(paymentDataSelector); const { - enableStripePaymentProcessor, loading, submitting, products, isDynamicPaymentMethodsEnabled, + enableStripePaymentProcessor, + loading, + submitting, + products, + isDynamicPaymentMethodsEnabled, + currency, + locationCountryCode, + orderTotal, } = checkoutDetails; + // Check if should show PaymentMethodMessagingElement, as it only renders + // for specific countries, if country code and currency are known, and they must match + const userLocationCountryCode = new Cookies().get(getConfig().LOCATION_OVERRIDE_COOKIE) + || new Cookies().get(getConfig().USER_LOCATION_COOKIE_NAME); + const shouldDisplayPaymentMethodMessagingElement = ( + (!!userLocationCountryCode || !!locationCountryCode) && !!orderTotal && !!currency + ); + // Loading button should appear when: basket and stripe elements are loading, quantity is updating and not submitting // isQuantityUpdating is true when isBasketProcessing is true when there is an update in the quantity for // bulk purchases but also happens on submit, when the 'processing' button state should show instead @@ -186,6 +204,15 @@ const StripePaymentForm = ({ onReady={stripeElementsOnReady} onChange={handleStripeElementOnChange} /> + {shouldDisplayPaymentMethodMessagingElement ? ( + + ) : null } {isSubscription ? ( <> diff --git a/src/payment/data/redux.test.js b/src/payment/data/redux.test.js index 05862e558..b9299f8ba 100644 --- a/src/payment/data/redux.test.js +++ b/src/payment/data/redux.test.js @@ -38,6 +38,7 @@ describe('redux tests', () => { expect(result).toEqual({ currencyCode: undefined, conversionRate: undefined, + locationCountryCode: undefined, showAsLocalizedCurrency: false, }); }); @@ -46,12 +47,14 @@ describe('redux tests', () => { Cookies.result = { code: 'USD', rate: 1, + countryCode: 'US', }; const result = localizedCurrencySelector(); expect(result).toEqual({ currencyCode: 'USD', conversionRate: 1, + locationCountryCode: 'US', showAsLocalizedCurrency: false, }); }); @@ -60,12 +63,14 @@ describe('redux tests', () => { Cookies.result = { code: 'EUR', rate: 1.5, + countryCode: 'FR', }; const result = localizedCurrencySelector(); expect(result).toEqual({ currencyCode: 'EUR', conversionRate: 1.5, + locationCountryCode: 'FR', showAsLocalizedCurrency: true, }); }); diff --git a/src/payment/data/utils.js b/src/payment/data/utils.js index 85675331f..0ce13e80c 100644 --- a/src/payment/data/utils.js +++ b/src/payment/data/utils.js @@ -193,10 +193,12 @@ export const localizedCurrencySelector = () => { const cookie = new Cookies().get(getConfig().CURRENCY_COOKIE_NAME); let currencyCode; let conversionRate; + let locationCountryCode; if (cookie && typeof cookie.code === 'string' && typeof cookie.rate === 'number') { currencyCode = cookie.code; conversionRate = cookie.rate; + locationCountryCode = cookie.countryCode; } const showAsLocalizedCurrency = typeof currencyCode === 'string' ? currencyCode !== 'USD' : false; @@ -204,6 +206,7 @@ export const localizedCurrencySelector = () => { return { currencyCode, conversionRate, + locationCountryCode, showAsLocalizedCurrency, }; }; From 9c00e5f52edb796ef0648ede26fc618975b1f7da Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 25 Mar 2024 12:03:58 -0400 Subject: [PATCH 08/25] refactor: Modify checkout function to handleNextAction for DPM payments --- src/payment/payment-methods/stripe/service.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/payment/payment-methods/stripe/service.js b/src/payment/payment-methods/stripe/service.js index 13ea42550..0df397b3d 100644 --- a/src/payment/payment-methods/stripe/service.js +++ b/src/payment/payment-methods/stripe/service.js @@ -95,9 +95,26 @@ export default async function checkout( }, ) .then(async response => { + // If response contains receipt_page_url, it's not a DPM payment if (response.data.receipt_page_url) { setLocation(response.data.receipt_page_url); } + if (response.data.status === 'requires_action') { + const { error } = await stripe.handleNextAction({ + clientSecret: response.data.confirmation_client_secret, + }); + + if (error) { + // Log error and tell user. + logError(error, { + messagePrefix: 'Stripe Submit Error', + paymentMethod: 'Stripe', + paymentErrorType: 'Submit Error', + basketId, + }); + handleApiError(error); + } + } }) .catch(error => { const errorData = error.response ? error.response.data : null; From eaaed95ea882ce4836a0d5819b95b2d1e50a61da Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 25 Mar 2024 13:49:53 -0400 Subject: [PATCH 09/25] fix: Add isPaymentRedirect to selectors for redirect to receipt --- src/payment/data/redux.test.js | 2 ++ src/payment/data/selectors.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/payment/data/redux.test.js b/src/payment/data/redux.test.js index b9299f8ba..d1168905b 100644 --- a/src/payment/data/redux.test.js +++ b/src/payment/data/redux.test.js @@ -112,6 +112,7 @@ describe('redux tests', () => { isCouponRedeemRedirect: false, isBasketProcessing: false, isEmpty: false, + isPaymentRedirect: false, isRedirect: false, }); }); @@ -132,6 +133,7 @@ describe('redux tests', () => { isCouponRedeemRedirect: true, // this is now true isBasketProcessing: false, isEmpty: false, + isPaymentRedirect: false, isRedirect: true, // this is also now true. }); }); diff --git a/src/payment/data/selectors.js b/src/payment/data/selectors.js index 5b1ae792b..6bb5ee1f5 100644 --- a/src/payment/data/selectors.js +++ b/src/payment/data/selectors.js @@ -37,9 +37,11 @@ export const paymentSelector = createSelector( (basket, queryParams) => { const isCouponRedeemRedirect = !!queryParams && queryParams.coupon_redeem_redirect == 1; // eslint-disable-line eqeqeq + const isPaymentRedirect = !!queryParams && queryParams.redirect_status === 'succeeded'; return { ...basket, isCouponRedeemRedirect, + isPaymentRedirect, isEmpty: basket.loaded && !basket.redirect && (!basket.products || basket.products.length === 0), isRedirect: From a641b79c63fcc8296269cf485ad4ed6a42ca3ce7 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 22 Mar 2024 20:19:17 -0400 Subject: [PATCH 10/25] refactor: Modify PaymentPage to account for redirect from DPM and redirect to receipt --- src/payment/PaymentPage.jsx | 87 +++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/src/payment/PaymentPage.jsx b/src/payment/PaymentPage.jsx index 597ed74b9..c4d2965f7 100644 --- a/src/payment/PaymentPage.jsx +++ b/src/payment/PaymentPage.jsx @@ -1,11 +1,20 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { loadStripe } from '@stripe/stripe-js'; +import { getConfig } from '@edx/frontend-platform'; +import { + FormattedMessage, + getLocale, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; +import { logInfo } from '@edx/frontend-platform/logging'; import { AppContext } from '@edx/frontend-platform/react'; import { sendPageEvent } from '@edx/frontend-platform/analytics'; import messages from './PaymentPage.messages'; +import { handleApiError } from './data/handleRequestError'; // Actions import { fetchBasket } from './data/actions'; @@ -38,6 +47,9 @@ class PaymentPage extends React.Component { REV1045Experiment, isTransparentPricingExperiment, enrollmentCountData, + stripe: null, + paymentStatus: null, // only available when redirected back to payment.edx.org from dynamic payment methods + orderNumber: null, // order number associated with the Payment Intent from dynamic payment methods }; } @@ -46,15 +58,80 @@ class PaymentPage extends React.Component { this.props.fetchBasket(); } + componentDidUpdate(prevProps) { + const { enableStripePaymentProcessor } = this.props; + if (!prevProps.enableStripePaymentProcessor && enableStripePaymentProcessor) { + this.initializeStripe(); + this.getPaymentStatus(); + } + } + + getPaymentStatus() { + const searchParams = new URLSearchParams(global.location.search); + const redirectStatus = searchParams.get('redirect_status'); + this.setState({ paymentStatus: redirectStatus }); + } + + initializeStripe = async () => { + const stripePromise = await loadStripe(process.env.STRIPE_PUBLISHABLE_KEY, { + betas: [process.env.STRIPE_BETA_FLAG], + apiVersion: process.env.STRIPE_API_VERSION, + locale: getLocale(), + }); + this.setState({ stripe: stripePromise }, () => { + this.retrieveOrderNumber(); + }); + }; + + retrieveOrderNumber = async () => { + // Get Payment Intent to retrieve the order number associated with this DPM payment. + // If this is not a Stripe dynamic payment methods (BNPL), URL will not contain any params + // and should not retrieve the Payment Intent. + const searchParams = new URLSearchParams(global.location.search); + const clientSecretId = searchParams.get('payment_intent_client_secret'); + if (clientSecretId) { + const { paymentIntent, error } = await this.state.stripe.retrievePaymentIntent(clientSecretId); + if (error) { handleApiError(error); } + this.setState({ orderNumber: paymentIntent.description }); + } + }; + + redirectToReceiptPage(orderNumber) { + logInfo(`Payment succeeded for edX order number ${orderNumber}, redirecting to ecommerce receipt page.`); + const queryParams = `order_number=${orderNumber}&disable_back_button=${Number(true)}`; + if (getConfig().ENVIRONMENT !== 'test') { + /* istanbul ignore next */ + global.location.assign(`${getConfig().ECOMMERCE_BASE_URL}/checkout/receipt/?${queryParams}`); + } + } + renderContent() { - const { isEmpty, isRedirect } = this.props; + const { isEmpty, isRedirect, isPaymentRedirect } = this.props; + const { isNumEnrolledExperiment, REV1045Experiment, isTransparentPricingExperiment, enrollmentCountData, + paymentStatus, + stripe, + orderNumber, } = this.state; + // If this is a redirect from Stripe Dynamic Payment Methods with a successful payment, redirect to the receipt page + if (paymentStatus === 'succeeded') { + this.redirectToReceiptPage(orderNumber); + } + + // If this is a redirect from Stripe Dynamic Payment Methods, show loading icon until getPaymentStatus is done. + if (isPaymentRedirect) { + return ( + + ); + } + // If we're going to be redirecting to another page instead of showing the user the interface, // show a minimal spinner while the redirect is happening. In other cases we want to show the // page skeleton, but in this case that would be misleading. @@ -98,7 +175,7 @@ class PaymentPage extends React.Component { />
- + { stripe ? : }
); @@ -123,6 +200,8 @@ PaymentPage.propTypes = { intl: intlShape.isRequired, isEmpty: PropTypes.bool, isRedirect: PropTypes.bool, + isPaymentRedirect: PropTypes.bool, + enableStripePaymentProcessor: PropTypes.bool, fetchBasket: PropTypes.func.isRequired, summaryQuantity: PropTypes.number, summarySubtotal: PropTypes.number, @@ -131,6 +210,8 @@ PaymentPage.propTypes = { PaymentPage.defaultProps = { isEmpty: false, isRedirect: false, + isPaymentRedirect: false, + enableStripePaymentProcessor: false, summaryQuantity: undefined, summarySubtotal: undefined, }; From ee959b46b207e83b548c6907fde30268c96dac76 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 22 Mar 2024 20:25:39 -0400 Subject: [PATCH 11/25] refactor: Use stripe promise from PaymentPage parent in Checkout --- src/payment/checkout/Checkout.jsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/payment/checkout/Checkout.jsx b/src/payment/checkout/Checkout.jsx index 7831f459a..e2a32163d 100644 --- a/src/payment/checkout/Checkout.jsx +++ b/src/payment/checkout/Checkout.jsx @@ -2,10 +2,8 @@ import React from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { loadStripe } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; import { - getLocale, FormattedMessage, injectIntl, intlShape, @@ -157,6 +155,7 @@ class Checkout extends React.Component { paymentMethod, submitting, orderType, + stripe, } = this.props; const submissionDisabled = loading || isBasketProcessing; const isBulkOrder = orderType === ORDER_TYPES.BULK_ENROLLMENT; @@ -223,16 +222,6 @@ class Checkout extends React.Component { const shouldDisplayStripePaymentForm = !loading && enableStripePaymentProcessor && options.clientSecret; const shouldDisplayCyberSourcePaymentForm = !loading && !enableStripePaymentProcessor; - // Doing this within the Checkout component so locale is configured and available - let stripePromise; - if (shouldDisplayStripePaymentForm) { - stripePromise = loadStripe(process.env.STRIPE_PUBLISHABLE_KEY, { - betas: [process.env.STRIPE_BETA_FLAG], - apiVersion: process.env.STRIPE_API_VERSION, - locale: getLocale(), - }); - } - return ( <>
@@ -269,7 +258,7 @@ class Checkout extends React.Component { since the flag will not be needed when we remove CyberSource. This is not needed in CyberSource form component since the default is set to false. */} {shouldDisplayStripePaymentForm ? ( - + Date: Thu, 28 Mar 2024 23:18:25 -0400 Subject: [PATCH 12/25] fix: Update invalid_request_error to include error in missing state and zip code --- src/payment/data/handleRequestError.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/payment/data/handleRequestError.js b/src/payment/data/handleRequestError.js index cbe902d77..90e17f885 100644 --- a/src/payment/data/handleRequestError.js +++ b/src/payment/data/handleRequestError.js @@ -99,7 +99,9 @@ export default function handleRequestError(error) { } // Country not DPM compatible - if (error.type === 'invalid_request_error' && error.param === 'payment_method_data[billing_details][address][country]') { + if (error.type === 'invalid_request_error' && ( + error.param === 'payment_method_data[billing_details][address][country]' || error.param === 'billing_details[address][state]' || error.param === 'billing_details[address][postal_code]' + )) { logInfo('Dynamic Payment Method Country Error', error.param); handleApiErrors([ { From fd2bccc4edb26c1809b4759a542a3558ef6c7156 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Thu, 28 Mar 2024 23:20:53 -0400 Subject: [PATCH 13/25] fix: Only Klarna has redirect_status on return_url, cannot rely on it --- src/payment/PaymentPage.jsx | 16 +++++----------- src/payment/data/selectors.js | 3 ++- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/payment/PaymentPage.jsx b/src/payment/PaymentPage.jsx index c4d2965f7..729ba6cb2 100644 --- a/src/payment/PaymentPage.jsx +++ b/src/payment/PaymentPage.jsx @@ -62,16 +62,9 @@ class PaymentPage extends React.Component { const { enableStripePaymentProcessor } = this.props; if (!prevProps.enableStripePaymentProcessor && enableStripePaymentProcessor) { this.initializeStripe(); - this.getPaymentStatus(); } } - getPaymentStatus() { - const searchParams = new URLSearchParams(global.location.search); - const redirectStatus = searchParams.get('redirect_status'); - this.setState({ paymentStatus: redirectStatus }); - } - initializeStripe = async () => { const stripePromise = await loadStripe(process.env.STRIPE_PUBLISHABLE_KEY, { betas: [process.env.STRIPE_BETA_FLAG], @@ -79,12 +72,12 @@ class PaymentPage extends React.Component { locale: getLocale(), }); this.setState({ stripe: stripePromise }, () => { - this.retrieveOrderNumber(); + this.retrievePaymentIntentInfo(); }); }; - retrieveOrderNumber = async () => { - // Get Payment Intent to retrieve the order number associated with this DPM payment. + retrievePaymentIntentInfo = async () => { + // Get Payment Intent to retrieve the payment status and order number associated with this DPM payment. // If this is not a Stripe dynamic payment methods (BNPL), URL will not contain any params // and should not retrieve the Payment Intent. const searchParams = new URLSearchParams(global.location.search); @@ -93,6 +86,7 @@ class PaymentPage extends React.Component { const { paymentIntent, error } = await this.state.stripe.retrievePaymentIntent(clientSecretId); if (error) { handleApiError(error); } this.setState({ orderNumber: paymentIntent.description }); + this.setState({ paymentStatus: paymentIntent.status }); } }; @@ -124,7 +118,7 @@ class PaymentPage extends React.Component { } // If this is a redirect from Stripe Dynamic Payment Methods, show loading icon until getPaymentStatus is done. - if (isPaymentRedirect) { + if (isPaymentRedirect && (paymentStatus !== 'requires_payment_method' || paymentStatus !== 'canceled')) { return ( { const isCouponRedeemRedirect = !!queryParams && queryParams.coupon_redeem_redirect == 1; // eslint-disable-line eqeqeq - const isPaymentRedirect = !!queryParams && queryParams.redirect_status === 'succeeded'; + const isPaymentRedirect = !!queryParams + && Boolean(queryParams.payment_intent); // Only klarna has redirect_status on URL return { ...basket, isCouponRedeemRedirect, From 0a8c5843769255f407421999d3f8798b5b57959b Mon Sep 17 00:00:00 2001 From: julianajlk Date: Fri, 29 Mar 2024 10:46:19 -0400 Subject: [PATCH 14/25] fix: Remove no longer needed PaymentIntentUnexpectedStateError, handling from BE instead --- .../formatted-alert-list/FormattedAlertList.jsx | 4 ---- src/feedback/data/sagas.js | 2 +- src/payment/AlertCodeMessages.jsx | 12 +----------- src/payment/data/handleRequestError.js | 5 +---- 4 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/components/formatted-alert-list/FormattedAlertList.jsx b/src/components/formatted-alert-list/FormattedAlertList.jsx index f66829ccb..8728d0b95 100644 --- a/src/components/formatted-alert-list/FormattedAlertList.jsx +++ b/src/components/formatted-alert-list/FormattedAlertList.jsx @@ -8,7 +8,6 @@ import { EnrollmentCodeQuantityUpdated, TransactionDeclined, BasketChangedError, - PaymentIntentUnexpectedStateError, DynamicPaymentMethodsNotCompatibleError, CaptureKeyTimeoutTwoMinutes, CaptureKeyTimeoutOneMinute, @@ -49,9 +48,6 @@ export const FormattedAlertList = (props) => { 'basket-changed-error-message': ( ), - 'payment-intent-unexpected-state': ( - - ), 'dynamic-payment-methods-country-not-compatible': ( ), diff --git a/src/feedback/data/sagas.js b/src/feedback/data/sagas.js index 3dfb5d5b4..3a65e1cb6 100644 --- a/src/feedback/data/sagas.js +++ b/src/feedback/data/sagas.js @@ -19,7 +19,7 @@ export function* handleErrors(e, clearExistingMessages) { if (e.errors !== undefined) { for (let i = 0; i < e.errors.length; i++) { // eslint-disable-line no-plusplus const error = e.errors[i]; - if (error.code === 'basket-changed-error-message' || error.code === 'payment-intent-unexpected-state' || error.code === 'dynamic-payment-methods-country-not-compatible') { + if (error.code === 'basket-changed-error-message' || error.code === 'dynamic-payment-methods-country-not-compatible') { yield put(addMessage(error.code, error.userMessage, {}, MESSAGE_TYPES.ERROR)); } else if (error.data === undefined && error.messageType === null) { yield put(addMessage('transaction-declined-message', error.userMessage, {}, MESSAGE_TYPES.ERROR)); diff --git a/src/payment/AlertCodeMessages.jsx b/src/payment/AlertCodeMessages.jsx index e42afee02..e9f4145a5 100644 --- a/src/payment/AlertCodeMessages.jsx +++ b/src/payment/AlertCodeMessages.jsx @@ -73,20 +73,10 @@ export const TransactionDeclined = () => ( /> ); -export const PaymentIntentUnexpectedStateError = () => ( - // TEMP TODO: temp copy, not approved by Product/UX yet - -); - export const DynamicPaymentMethodsNotCompatibleError = () => ( - // TEMP TODO: temp copy, not approved by Product/UX yet ); diff --git a/src/payment/data/handleRequestError.js b/src/payment/data/handleRequestError.js index 90e17f885..9ef40024d 100644 --- a/src/payment/data/handleRequestError.js +++ b/src/payment/data/handleRequestError.js @@ -89,10 +89,7 @@ export default function handleRequestError(error) { logInfo('Basket Changed Error', error.code); handleApiErrors([ { - error_code: 'payment-intent-unexpected-state', - // TEMP TODO: Now that we have different Payment Intent statuses, this type of error can mean different things - // Which message did it say when the basket was already purchased? - // error_code: 'payment-intent-unexpected-state', + error_code: 'basket-changed-error-message', user_message: 'error', }, ]); From 521c606e3df16d9a515e409580731210bca8450b Mon Sep 17 00:00:00 2001 From: julianajlk Date: Tue, 9 Apr 2024 09:19:17 -0400 Subject: [PATCH 15/25] fix: Reorder error type in handleRequestError from feedback --- src/payment/data/handleRequestError.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/payment/data/handleRequestError.js b/src/payment/data/handleRequestError.js index 9ef40024d..5a2a0a9bd 100644 --- a/src/payment/data/handleRequestError.js +++ b/src/payment/data/handleRequestError.js @@ -84,25 +84,25 @@ export default function handleRequestError(error) { ]); } - // For a Payment Intent to be confirmable, it must be in requires_payment_method or requires_confirmation - if (error.code === 'payment_intent_unexpected_state' && error.type === 'invalid_request_error') { - logInfo('Basket Changed Error', error.code); + // Country not DPM compatible + if (error.type === 'invalid_request_error' && ( + error.param === 'payment_method_data[billing_details][address][country]' || error.param === 'billing_details[address][state]' || error.param === 'billing_details[address][postal_code]' + )) { + logInfo('Dynamic Payment Method Country Error', error.param); handleApiErrors([ { - error_code: 'basket-changed-error-message', + error_code: 'dynamic-payment-methods-country-not-compatible', user_message: 'error', }, ]); } - // Country not DPM compatible - if (error.type === 'invalid_request_error' && ( - error.param === 'payment_method_data[billing_details][address][country]' || error.param === 'billing_details[address][state]' || error.param === 'billing_details[address][postal_code]' - )) { - logInfo('Dynamic Payment Method Country Error', error.param); + // For a Payment Intent to be confirmable, it must be in requires_payment_method or requires_confirmation + if (error.code === 'payment_intent_unexpected_state' && error.type === 'invalid_request_error') { + logInfo('Basket Changed Error', error.code); handleApiErrors([ { - error_code: 'dynamic-payment-methods-country-not-compatible', + error_code: 'basket-changed-error-message', user_message: 'error', }, ]); From e1b548d73be41242745e3088f8b8140b7b3db92d Mon Sep 17 00:00:00 2001 From: julianajlk Date: Tue, 9 Apr 2024 09:21:00 -0400 Subject: [PATCH 16/25] test: Add more tests --- src/payment/AlertCodeMessages.test.jsx | 26 +++++++++++++++++++ .../AlertCodeMessages.test.jsx.snap | 12 +++++++++ .../CardHolderInformation.test.jsx | 2 +- src/payment/data/redux.test.js | 21 +++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/payment/AlertCodeMessages.test.jsx b/src/payment/AlertCodeMessages.test.jsx index 37fbe2ebf..43758389e 100644 --- a/src/payment/AlertCodeMessages.test.jsx +++ b/src/payment/AlertCodeMessages.test.jsx @@ -8,6 +8,8 @@ import { SingleEnrollmentCodeWarning, EnrollmentCodeQuantityUpdated, TransactionDeclined, + DynamicPaymentMethodsNotCompatibleError, + BasketChangedError, } from './AlertCodeMessages'; const mockStore = configureMockStore(); @@ -51,3 +53,27 @@ describe('TransactionDeclined', () => { expect(tree).toMatchSnapshot(); }); }); + +describe('DynamicPaymentMethodsNotCompatibleError', () => { + it('should render with values', () => { + const component = ( + + + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); +}); + +describe('BasketChangedError', () => { + it('should render with values', () => { + const component = ( + + + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap b/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap index 09d6ffd77..39ca5ab23 100644 --- a/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap +++ b/src/payment/__snapshots__/AlertCodeMessages.test.jsx.snap @@ -1,5 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`BasketChangedError should render with values 1`] = ` +
+ Your cart has changed since navigating to this page. Please reload the page and verify the product you are purchasing. +
+`; + +exports[`DynamicPaymentMethodsNotCompatibleError should render with values 1`] = ` +
+ The payment method you have selected is not available in your country. Please select another payment method. +
+`; + exports[`EnrollmentCodeQuantityUpdated should render with values 1`] = `
diff --git a/src/payment/checkout/payment-form/CardHolderInformation.test.jsx b/src/payment/checkout/payment-form/CardHolderInformation.test.jsx index 07481b214..2677badf3 100644 --- a/src/payment/checkout/payment-form/CardHolderInformation.test.jsx +++ b/src/payment/checkout/payment-form/CardHolderInformation.test.jsx @@ -74,7 +74,7 @@ describe('', () => { fireEvent.change(screen.getByLabelText('Country (required)'), { target: { value: 'US' } }); expect(getCountryStatesMap).toHaveBeenCalledWith('US'); - expect(isPostalCodeRequired).toHaveBeenCalledWith('US'); + expect(isPostalCodeRequired).toHaveBeenCalledWith('US', false); // DPM enabled added to the call }); }); describe('purchasedForOrganization field', () => { diff --git a/src/payment/data/redux.test.js b/src/payment/data/redux.test.js index d1168905b..356ed36d5 100644 --- a/src/payment/data/redux.test.js +++ b/src/payment/data/redux.test.js @@ -137,6 +137,27 @@ describe('redux tests', () => { isRedirect: true, // this is also now true. }); }); + + it('is a Stripe dynamic payment methods redirect', () => { + global.history.pushState({}, '', '?payment_intent=pi_123dummy'); + store = createStore(combineReducers({ + payment: reducer, + })); + + const result = paymentSelector(store.getState()); + expect(result).toEqual({ + loading: true, + loaded: false, + submitting: false, + redirect: false, // This is a different kind of redirect, so still false. + products: [], + isCouponRedeemRedirect: false, + isBasketProcessing: false, + isEmpty: false, + isPaymentRedirect: true, // this is now true + isRedirect: false, + }); + }); }); }); From b60c260ff87d38c58bc2f64e376c48164c9d7d0b Mon Sep 17 00:00:00 2001 From: julianajlk Date: Wed, 10 Apr 2024 10:39:06 -0400 Subject: [PATCH 17/25] fix: Add dpm_enabled query param to receipt URL --- src/payment/PaymentPage.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/payment/PaymentPage.jsx b/src/payment/PaymentPage.jsx index 729ba6cb2..9bdda4042 100644 --- a/src/payment/PaymentPage.jsx +++ b/src/payment/PaymentPage.jsx @@ -92,7 +92,7 @@ class PaymentPage extends React.Component { redirectToReceiptPage(orderNumber) { logInfo(`Payment succeeded for edX order number ${orderNumber}, redirecting to ecommerce receipt page.`); - const queryParams = `order_number=${orderNumber}&disable_back_button=${Number(true)}`; + const queryParams = `order_number=${orderNumber}&disable_back_button=${Number(true)}&dpm_enabled=${true}`; if (getConfig().ENVIRONMENT !== 'test') { /* istanbul ignore next */ global.location.assign(`${getConfig().ECOMMERCE_BASE_URL}/checkout/receipt/?${queryParams}`); From 7fa34adedb4169bab8697c72d5b7ae59970d1f73 Mon Sep 17 00:00:00 2001 From: julianajlk Date: Mon, 15 Apr 2024 13:32:28 -0400 Subject: [PATCH 18/25] fix: Add stripeSelectedPaymentMethod to payment_selected click event --- src/payment/checkout/Checkout.jsx | 4 ++-- .../checkout/payment-form/PlaceOrderButton.jsx | 6 ++++-- .../checkout/payment-form/StripePaymentForm.jsx | 11 ++++++----- src/payment/payment-methods/stripe/service.js | 4 ++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/payment/checkout/Checkout.jsx b/src/payment/checkout/Checkout.jsx index e2a32163d..03c3ceec6 100644 --- a/src/payment/checkout/Checkout.jsx +++ b/src/payment/checkout/Checkout.jsx @@ -93,13 +93,13 @@ class Checkout extends React.Component { this.props.submitPayment({ method: 'stripe', ...formData }); }; - handleSubmitStripeButtonClick = () => { + handleSubmitStripeButtonClick = (stripeSelectedPaymentMethod) => { sendTrackEvent( 'edx.bi.ecommerce.basket.payment_selected', { type: 'click', category: 'checkout', - paymentMethod: 'Credit Card - Stripe', + paymentMethod: stripeSelectedPaymentMethod === 'affirm' ? 'Affirm - Stripe' : 'Credit Card - Stripe', checkoutType: 'client_side', stripeEnabled: this.props.enableStripePaymentProcessor, }, diff --git a/src/payment/checkout/payment-form/PlaceOrderButton.jsx b/src/payment/checkout/payment-form/PlaceOrderButton.jsx index fec4a883d..22215bac9 100644 --- a/src/payment/checkout/payment-form/PlaceOrderButton.jsx +++ b/src/payment/checkout/payment-form/PlaceOrderButton.jsx @@ -4,7 +4,7 @@ import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { StatefulButton } from '@openedx/paragon'; const PlaceOrderButton = ({ - showLoadingButton, onSubmitButtonClick, disabled, isProcessing, + showLoadingButton, onSubmitButtonClick, stripeSelectedPaymentMethod, disabled, isProcessing, }) => { let submitButtonState = 'default'; // istanbul ignore if @@ -26,7 +26,7 @@ const PlaceOrderButton = ({ size="lg" block state={submitButtonState} - onClick={onSubmitButtonClick} + onClick={() => onSubmitButtonClick(stripeSelectedPaymentMethod)} labels={{ default: ( { + // Check payment type before submitting since BNPL requires state/zip code and Afterpay requires a shipping address. + // This is also used for analytics on "Place Order" click event. + const [stripeSelectedPaymentMethod, setStripeSelectedPaymentMethod] = useState(null); const stripe = useStripe(); const elements = useElements(); const context = useContext(AppContext); @@ -54,9 +57,6 @@ const StripePaymentForm = ({ const [firstErrorId, setfirstErrorId] = useState(false); const [shouldFocusFirstError, setshouldFocusFirstError] = useState(false); - // Check payment type before submitting since BNPL requires state/zip code and Afterpay requires a shipping address - let stripePaymentMethodType; - const checkoutDetails = useSelector(paymentDataSelector); const { enableStripePaymentProcessor, @@ -148,13 +148,13 @@ const StripePaymentForm = ({ } onSubmitPayment({ - skus, elements, stripe, context, values, stripePaymentMethodType, + skus, elements, stripe, context, values, stripeSelectedPaymentMethod, }); }; const handleStripeElementOnChange = (event) => { if (event.value) { - stripePaymentMethodType = event.value.type; + setStripeSelectedPaymentMethod(event.value.type); } }; @@ -227,6 +227,7 @@ const StripePaymentForm = ({ ) : ( { global.location.href = href; }, // HACK: allow tests to mock setting location ) { @@ -36,7 +36,7 @@ export default async function checkout( } = values; let shippingAddress; - if (stripePaymentMethodType === 'afterpay_clearpay') { + if (stripeSelectedPaymentMethod === 'afterpay_clearpay') { shippingAddress = { address: { city, From d71837f2ec58e17c9ecd3a6a079d73337b645a28 Mon Sep 17 00:00:00 2001 From: Juliana Kang Date: Tue, 23 Apr 2024 16:24:35 -0400 Subject: [PATCH 19/25] fix: Add country compatibility with BNPL Affirm at the form level (#5) REV-3830 --- audit-ci.json | 4 ++-- docs/how_tos/feedback.rst | 1 + src/payment/PaymentPage.jsx | 17 +++++++++----- src/payment/checkout/Checkout.jsx | 2 +- .../payment-form/PaymentForm.messages.jsx | 5 ++++ .../payment-form/StripePaymentForm.jsx | 11 ++++++++- .../payment-form/StripePaymentForm.test.jsx | 23 ++++++++++++++++++- .../payment-form/utils/form-validators.js | 20 ++++++++++++++++ 8 files changed, 72 insertions(+), 11 deletions(-) diff --git a/audit-ci.json b/audit-ci.json index b6b51da30..d598dccb1 100644 --- a/audit-ci.json +++ b/audit-ci.json @@ -1,10 +1,10 @@ { "allowlist": [ - "GHSA-hpx4-r86g-5jrg", "GHSA-wf5p-g6vw-rhxx", "GHSA-cxjh-pqwp-8mfp", "GHSA-wr3j-pwj9-hqq6", - "GHSA-rv95-896h-c2vc" + "GHSA-rv95-896h-c2vc", + "GHSA-8cp3-66vr-3r4c" ], "moderate": true } diff --git a/docs/how_tos/feedback.rst b/docs/how_tos/feedback.rst index 5e0b41d32..7a73c4446 100644 --- a/docs/how_tos/feedback.rst +++ b/docs/how_tos/feedback.rst @@ -374,6 +374,7 @@ pre-built APIs that do not follow the format of the feedback module in the: * `feedback/data/sagas.js`_ * ``basket-changed-error-message`` + * ``dynamic-payment-methods-country-not-compatible`` * ``transaction-declined-message`` * ``error_message`` in URL parameters diff --git a/src/payment/PaymentPage.jsx b/src/payment/PaymentPage.jsx index 9bdda4042..801f5e517 100644 --- a/src/payment/PaymentPage.jsx +++ b/src/payment/PaymentPage.jsx @@ -14,7 +14,7 @@ import { AppContext } from '@edx/frontend-platform/react'; import { sendPageEvent } from '@edx/frontend-platform/analytics'; import messages from './PaymentPage.messages'; -import { handleApiError } from './data/handleRequestError'; +import handleRequestError from './data/handleRequestError'; // Actions import { fetchBasket } from './data/actions'; @@ -80,13 +80,18 @@ class PaymentPage extends React.Component { // Get Payment Intent to retrieve the payment status and order number associated with this DPM payment. // If this is not a Stripe dynamic payment methods (BNPL), URL will not contain any params // and should not retrieve the Payment Intent. + // TODO: depending on if we'll use this MFE in the future, refactor to follow the redux pattern with actions + // and reducers for getting the Payment Intent is more appropriate. const searchParams = new URLSearchParams(global.location.search); const clientSecretId = searchParams.get('payment_intent_client_secret'); if (clientSecretId) { - const { paymentIntent, error } = await this.state.stripe.retrievePaymentIntent(clientSecretId); - if (error) { handleApiError(error); } - this.setState({ orderNumber: paymentIntent.description }); - this.setState({ paymentStatus: paymentIntent.status }); + try { + const { paymentIntent } = await this.state.stripe.retrievePaymentIntent(clientSecretId); + this.setState({ orderNumber: paymentIntent?.description }); + this.setState({ paymentStatus: paymentIntent?.status }); + } catch (error) { + handleRequestError(error); + } } }; @@ -118,7 +123,7 @@ class PaymentPage extends React.Component { } // If this is a redirect from Stripe Dynamic Payment Methods, show loading icon until getPaymentStatus is done. - if (isPaymentRedirect && (paymentStatus !== 'requires_payment_method' || paymentStatus !== 'canceled')) { + if (isPaymentRedirect && paymentStatus === null) { return ( 0 || stripeElementErrors) { diff --git a/src/payment/checkout/payment-form/StripePaymentForm.test.jsx b/src/payment/checkout/payment-form/StripePaymentForm.test.jsx index 2a43c9a8e..be7424aa1 100644 --- a/src/payment/checkout/payment-form/StripePaymentForm.test.jsx +++ b/src/payment/checkout/payment-form/StripePaymentForm.test.jsx @@ -23,7 +23,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({ jest.useFakeTimers('modern'); const validateRequiredFieldsMock = jest.spyOn(formValidators, 'validateRequiredFields'); - +const validateCountryPaymentMethodCompatibilityMock = jest.spyOn(formValidators, 'validateCountryPaymentMethodCompatibility'); const mockStore = configureMockStore(); configureI18n({ @@ -260,8 +260,10 @@ describe('', () => { const testData = [ [ { firstName: 'This field is required' }, + { country: 'Country not available with selected payment method' }, new SubmissionError({ firstName: 'This field is required', + country: 'Country not available with selected payment method', }), ], [ @@ -272,6 +274,7 @@ describe('', () => { testData.forEach((testCaseData) => { validateRequiredFieldsMock.mockReturnValueOnce(testCaseData[0]); + validateCountryPaymentMethodCompatibilityMock.mockReturnValueOnce(testCaseData[0]); if (testCaseData[1]) { expect(() => fireEvent.click(screen.getByText('Place Order'))); expect(submitStripePayment).not.toHaveBeenCalled(); @@ -294,4 +297,22 @@ describe('', () => { expect(formValidators.validateRequiredFields(values)).toEqual(expectedErrors); }); }); + + describe('validateCountryPaymentMethodCompatibility', () => { + it('returns errors if country is not compatible with Dynamic Payment Method (BNPL Affirm)', () => { + const values = { + country: 'BR', + }; + const expectedErrors = { + country: 'payment.form.errors.dynamic_payment_methods_not_compatible.country', + }; + const isDynamicPaymentMethodsEnabled = true; + const stripeSelectedPaymentMethod = 'affirm'; + expect(formValidators.validateCountryPaymentMethodCompatibility( + isDynamicPaymentMethodsEnabled, + stripeSelectedPaymentMethod, + values.country, + )).toEqual(expectedErrors); + }); + }); }); diff --git a/src/payment/checkout/payment-form/utils/form-validators.js b/src/payment/checkout/payment-form/utils/form-validators.js index 25a04ca39..f68246039 100644 --- a/src/payment/checkout/payment-form/utils/form-validators.js +++ b/src/payment/checkout/payment-form/utils/form-validators.js @@ -126,6 +126,26 @@ export function validateRequiredFields(values) { return errors; } +export function validateCountryPaymentMethodCompatibility( + isDynamicPaymentMethodsEnabled, + stripeSelectedPaymentMethod, + selectedCountry, +) { + const errors = {}; + + // Only adding country validation on the form level for BNPL Affirm. + // For Klarna, there is validation on the Stripe API level, + // which is handled with error code 'dynamic-payment-methods-country-not-compatible' + if (isDynamicPaymentMethodsEnabled && stripeSelectedPaymentMethod === 'affirm') { + const countryListCompatibleAffirm = ['CA', 'US']; + if (!countryListCompatibleAffirm.includes(selectedCountry)) { + errors.country = 'payment.form.errors.dynamic_payment_methods_not_compatible.country'; + } + } + + return errors; +} + export function validateCardDetails(cardExpirationMonth, cardExpirationYear) { const errors = {}; From 83a63fc916d2cf5c8470c6be8c250c83f98eb86e Mon Sep 17 00:00:00 2001 From: Juliana Kang Date: Fri, 26 Apr 2024 10:40:08 -0400 Subject: [PATCH 20/25] refactor: DPM loading lag for successful payments redirect (#6) REV-4009 --- src/payment/PageLoading.jsx | 20 ++++++++++++ src/payment/PaymentPage.jsx | 31 +++++++++---------- src/payment/cart/CouponForm.jsx | 2 +- .../cart/__snapshots__/Cart.test.jsx.snap | 1 + .../__snapshots__/CouponForm.test.jsx.snap | 1 + .../payment-form/StripePaymentForm.jsx | 6 ++-- src/payment/payment-methods/stripe/service.js | 2 +- 7 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/payment/PageLoading.jsx b/src/payment/PageLoading.jsx index de61fb2b7..15e55c167 100644 --- a/src/payment/PageLoading.jsx +++ b/src/payment/PageLoading.jsx @@ -1,5 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; +import { logInfo } from '@edx/frontend-platform/logging'; export default class PageLoading extends Component { renderSrMessage() { @@ -15,6 +17,17 @@ export default class PageLoading extends Component { } render() { + const { shouldRedirectToReceipt, orderNumber } = this.props; + + if (shouldRedirectToReceipt) { + logInfo(`Dynamic Payment Methods payment succeeded for edX order number ${orderNumber}, redirecting to ecommerce receipt page.`); + const queryParams = `order_number=${orderNumber}&disable_back_button=${Number(true)}&dpm_enabled=${true}`; + if (getConfig().ENVIRONMENT !== 'test') { + /* istanbul ignore next */ + global.location.assign(`${getConfig().ECOMMERCE_BASE_URL}/checkout/receipt/?${queryParams}`); + } + } + return (
+ ); + } + // In all other cases, we want to render the basket content. This is used before we've loaded // anything, during loading, and after we've loaded a basket with a product in it. diff --git a/src/payment/cart/CouponForm.jsx b/src/payment/cart/CouponForm.jsx index d65ad2469..a79619a8f 100644 --- a/src/payment/cart/CouponForm.jsx +++ b/src/payment/cart/CouponForm.jsx @@ -44,7 +44,7 @@ class CouponForm extends Component { return (
- +