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/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 ------------ diff --git a/audit-ci.json b/audit-ci.json index b6b51da30..6a826db6b 100644 --- a/audit-ci.json +++ b/audit-ci.json @@ -1,9 +1,6 @@ { "allowlist": [ - "GHSA-hpx4-r86g-5jrg", "GHSA-wf5p-g6vw-rhxx", - "GHSA-cxjh-pqwp-8mfp", - "GHSA-wr3j-pwj9-hqq6", "GHSA-rv95-896h-c2vc" ], "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/package-lock.json b/package-lock.json index bdc76c7f7..d0168321c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9466,9 +9466,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -19338,9 +19338,9 @@ } }, "node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", diff --git a/public/index.html b/public/index.html index 6e2eba53c..1b8ff47f7 100755 --- a/public/index.html +++ b/public/index.html @@ -26,6 +26,5 @@ - diff --git a/src/components/formatted-alert-list/FormattedAlertList.jsx b/src/components/formatted-alert-list/FormattedAlertList.jsx index 2a483b162..8728d0b95 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, + DynamicPaymentMethodsNotCompatibleError, CaptureKeyTimeoutTwoMinutes, CaptureKeyTimeoutOneMinute, } from '../../payment/AlertCodeMessages'; @@ -47,6 +48,9 @@ export const FormattedAlertList = (props) => { 'basket-changed-error-message': ( ), + '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 6d26ce835..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') { + 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 87c64b45c..e9f4145a5 100644 --- a/src/payment/AlertCodeMessages.jsx +++ b/src/payment/AlertCodeMessages.jsx @@ -73,6 +73,14 @@ export const TransactionDeclined = () => ( /> ); +export const DynamicPaymentMethodsNotCompatibleError = () => ( + +); + export const BasketChangedError = () => ( { 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/PageLoadingDynamicPaymentMethods.jsx b/src/payment/PageLoadingDynamicPaymentMethods.jsx new file mode 100644 index 000000000..dc68a1b03 --- /dev/null +++ b/src/payment/PageLoadingDynamicPaymentMethods.jsx @@ -0,0 +1,58 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; +import { logInfo } from '@edx/frontend-platform/logging'; + +const PageLoadingDynamicPaymentMethods = ({ srMessage, orderNumber }) => { + useEffect(() => { + const timer = setTimeout(() => { + 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}`); + } + }, 3000); // Delay the redirect to receipt page by 3 seconds to make sure ecomm order fulfillment is done. + + return () => clearTimeout(timer); // On unmount, clear the timer + }, [srMessage, orderNumber]); + + const renderSrMessage = () => { + if (!srMessage) { + return null; + } + + return ( + + {srMessage} + + ); + }; + + return ( +
+
+
+ {renderSrMessage()} +
+
+
+ ); +}; + +PageLoadingDynamicPaymentMethods.propTypes = { + srMessage: PropTypes.string.isRequired, + orderNumber: PropTypes.string, +}; + +PageLoadingDynamicPaymentMethods.defaultProps = { + orderNumber: null, +}; + +export default PageLoadingDynamicPaymentMethods; diff --git a/src/payment/PageLoadingDynamicPaymentMethods.test.jsx b/src/payment/PageLoadingDynamicPaymentMethods.test.jsx new file mode 100644 index 000000000..192fd2e75 --- /dev/null +++ b/src/payment/PageLoadingDynamicPaymentMethods.test.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; +import { render, act } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { logInfo } from '@edx/frontend-platform/logging'; + +import createRootReducer from '../data/reducers'; +import PageLoadingDynamicPaymentMethods from './PageLoadingDynamicPaymentMethods'; + +jest.mock('@edx/frontend-platform/logging', () => ({ + logInfo: jest.fn(), +})); + +describe('PageLoadingDynamicPaymentMethods', () => { + let store; + + beforeEach(() => { + store = createStore(createRootReducer()); + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders ', () => { + const component = ( + + + + + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('it redirects to receipt page after 3 seconds delay', () => { + const orderNumber = 'EDX-100001'; + const logMessage = `Dynamic Payment Methods payment succeeded for edX order number ${orderNumber}, redirecting to ecommerce receipt page.`; + render( + + + + + , + ); + + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(logInfo).toHaveBeenCalledWith(expect.stringMatching(logMessage)); + }); + + it('cleans up the timer on unmount', () => { + const { unmount } = render( + , + ); + unmount(); + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(logInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/payment/PaymentPage.jsx b/src/payment/PaymentPage.jsx index 597ed74b9..bdf43cd4a 100644 --- a/src/payment/PaymentPage.jsx +++ b/src/payment/PaymentPage.jsx @@ -1,11 +1,18 @@ 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 { + FormattedMessage, + getLocale, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; import { sendPageEvent } from '@edx/frontend-platform/analytics'; import messages from './PaymentPage.messages'; +import handleRequestError from './data/handleRequestError'; // Actions import { fetchBasket } from './data/actions'; @@ -19,6 +26,7 @@ import EmptyCartMessage from './EmptyCartMessage'; import Cart from './cart/Cart'; import Checkout from './checkout/Checkout'; import { FormattedAlertList } from '../components/formatted-alert-list/FormattedAlertList'; +import PageLoadingDynamicPaymentMethods from './PageLoadingDynamicPaymentMethods'; class PaymentPage extends React.Component { constructor(props) { @@ -38,6 +46,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 +57,79 @@ class PaymentPage extends React.Component { this.props.fetchBasket(); } + componentDidUpdate(prevProps) { + const { enableStripePaymentProcessor } = this.props; + if (!prevProps.enableStripePaymentProcessor && enableStripePaymentProcessor) { + this.initializeStripe(); + } + } + + 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.retrievePaymentIntentInfo(); + }); + }; + + 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. + // 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) { + try { + const { paymentIntent } = await this.state.stripe.retrievePaymentIntent(clientSecretId); + this.setState({ orderNumber: paymentIntent?.description }); + this.setState({ paymentStatus: paymentIntent?.status }); + } catch (error) { + handleRequestError(error); + } + } + }; + renderContent() { - const { isEmpty, isRedirect } = this.props; + const { isEmpty, isRedirect, isPaymentRedirect } = this.props; + const { isNumEnrolledExperiment, REV1045Experiment, isTransparentPricingExperiment, enrollmentCountData, + paymentStatus, + stripe, + orderNumber, } = this.state; + const shouldRedirectToReceipt = paymentStatus === 'succeeded'; + + // If this is a redirect from Stripe Dynamic Payment Methods with a successful payment, + // redirect to the receipt page. PageLoading render is required first otherwise there is a + // lag between when the paymentStatus is no longer null but the redirect hasn't happened yet. + if (shouldRedirectToReceipt) { + return ( + + ); + } + + // If this is a redirect from Stripe Dynamic Payment Methods, show loading icon until getPaymentStatus is done. + if (isPaymentRedirect && paymentStatus !== undefined && paymentStatus === null) { + 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 +173,7 @@ class PaymentPage extends React.Component { />
- + { stripe ? : }
); @@ -123,6 +198,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 +208,8 @@ PaymentPage.propTypes = { PaymentPage.defaultProps = { isEmpty: false, isRedirect: false, + isPaymentRedirect: false, + enableStripePaymentProcessor: false, summaryQuantity: undefined, summarySubtotal: undefined, }; 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/__snapshots__/PageLoadingDynamicPaymentMethods.test.jsx.snap b/src/payment/__snapshots__/PageLoadingDynamicPaymentMethods.test.jsx.snap new file mode 100644 index 000000000..c3dda9f75 --- /dev/null +++ b/src/payment/__snapshots__/PageLoadingDynamicPaymentMethods.test.jsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageLoadingDynamicPaymentMethods renders 1`] = ` +
+
+
+
+
+
+
+`; 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 (
- +