diff --git a/audit-ci.json b/audit-ci.json index db7ecb9a1..e4620db03 100644 --- a/audit-ci.json +++ b/audit-ci.json @@ -9,7 +9,9 @@ "GHSA-pfrx-2q88-qq97", "GHSA-rc47-6667-2j5j", "GHSA-hc6q-2mpp-qw7j", - "GHSA-f9xv-q969-pqx4" + "GHSA-f9xv-q969-pqx4", + "GHSA-c2qf-rxjj-qqgw", + "GHSA-j8xg-fqg3-53r7" ], "moderate": true } diff --git a/src/payment/PaymentPage.jsx b/src/payment/PaymentPage.jsx index 1e6ba7358..7dc103ac9 100644 --- a/src/payment/PaymentPage.jsx +++ b/src/payment/PaymentPage.jsx @@ -19,6 +19,7 @@ import EmptyCartMessage from './EmptyCartMessage'; import Cart from './cart/Cart'; import Checkout from './checkout/Checkout'; import { FormattedAlertList } from '../components/formatted-alert-list/FormattedAlertList'; +import { PaymentProcessingModal } from './PaymentProcessingModal'; class PaymentPage extends React.Component { constructor(props) { @@ -96,6 +97,7 @@ class PaymentPage extends React.Component { />
+ { + /** + * Determine if the Dialog should be open based on Redux state input + * @param s {PAYMENT_STATE} The value of the payment state as we currently know it (`paymentProcessStatusSelector`) + * @param p {boolean} is currently polling/still polling for status (`paymentProcessStatusIsPollingSelector`) + * @return {boolean} + */ + const shouldBeOpen = (s, p) => p || POLLING_PAYMENT_STATES.includes(s); + const intl = useIntl(); - const shouldBeOpen = (status) => status === 'pending'; + const status = useSelector(paymentProcessStatusSelector); - const [isOpen, setOpen] = useState(shouldBeOpen(status)); + const isPolling = useSelector(paymentProcessStatusIsPollingSelector); + const [isOpen, setOpen] = useState(shouldBeOpen(status, isPolling)); useEffect(() => { - setOpen(shouldBeOpen(status)); - }, [status]); + setOpen(shouldBeOpen(status, isPolling)); + }, [status, isPolling]); - if (!isOpen) { return null; } + if (!isOpen) { + return null; + } return ( {}} + onClose={() => { /* Noop, @see pollPaymentState fulfill */ }} hasCloseButton={false} isFullscreenOnMobile={false} > @@ -51,4 +72,4 @@ export const PaymentProcessingModal = () => { ); }; -export default PaymentProcessingModal; +export default connect()(injectIntl(PaymentProcessingModal)); diff --git a/src/payment/PaymentProcessingModal.test.jsx b/src/payment/PaymentProcessingModal.test.jsx index 93344ce08..dfa87cfda 100644 --- a/src/payment/PaymentProcessingModal.test.jsx +++ b/src/payment/PaymentProcessingModal.test.jsx @@ -9,8 +9,9 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { Provider } from 'react-redux'; import React from 'react'; import { PaymentProcessingModal } from './PaymentProcessingModal'; +import { PAYMENT_STATE } from './data/constants'; -const basketStoreGenerator = (isProcessing) => ( +const basketStoreGenerator = (paymentState = PAYMENT_STATE.DEFAULT, keepPolling = false) => ( { payment: { basket: { @@ -18,9 +19,18 @@ const basketStoreGenerator = (isProcessing) => ( loaded: false, submitting: false, redirect: false, - isBasketProcessing: isProcessing, + isBasketProcessing: false, products: [{ sku: '00000' }], enableStripePaymentProcessor: true, + /** Modified by both getActiveOrder and paymentStatePolling */ + // eslint-disable-next-line object-shorthand + paymentState: paymentState, + /** state specific to paymentStatePolling */ + paymentStatePolling: { + // eslint-disable-next-line object-shorthand + keepPolling: keepPolling, // TODO: GRM: FIX both debugging items (this is one) + counter: 5, // debugging + }, }, clientSecret: { isClientSecretProcessing: false, clientSecretId: '' }, }, @@ -29,9 +39,9 @@ const basketStoreGenerator = (isProcessing) => ( } ); -const statusStringToBool = (str) => str === 'pending'; +const shouldPoll = (payState) => payState === PAYMENT_STATE.PENDING; -const buildDescription = (tp) => `is ${statusStringToBool(tp.status) ? '' : 'NOT '}shown (status == '${tp.status}')`; +const buildDescription = (tp) => `is ${shouldPoll(tp.status) ? '' : 'NOT '}shown (status == '${tp.status}')`; /** * PaymentProcessingModal Test @@ -68,18 +78,21 @@ describe('', () => { }, }); + /* eslint-disable no-multi-spaces */ const tests = [ - { expect: true, status: 'pending' }, - { expect: false, status: 'failed' }, - { expect: false, status: 'draft' }, - { expect: false, status: 'checkout' }, + { expect: true, status: PAYMENT_STATE.PENDING }, + { expect: false, status: PAYMENT_STATE.FAILED }, + { expect: false, status: PAYMENT_STATE.DEFAULT }, + { expect: false, status: PAYMENT_STATE.CHECKOUT }, + { expect: false, status: PAYMENT_STATE.COMPLETED }, ]; + /* eslint-enable no-multi-spaces */ for (let i = 0, testPlan = tests[i]; i < tests.length; i++, testPlan = tests[i]) { describe(buildDescription(testPlan), () => { - it(`${statusStringToBool(testPlan.status) ? 'renders a dialog' : 'does not render a dialog'}`, () => { + it(`${shouldPoll(testPlan.status) ? 'renders a dialog' : 'does not render a dialog'}`, () => { const mockStore = configureMockStore(); - const store = mockStore(basketStoreGenerator(statusStringToBool(testPlan.status))); + const store = mockStore(basketStoreGenerator(testPlan.status, shouldPoll(testPlan.status))); const wrapper = mount(( diff --git a/src/payment/checkout/payment-form/StripePaymentForm.jsx b/src/payment/checkout/payment-form/StripePaymentForm.jsx index 4d6221140..a1f2dbcf1 100644 --- a/src/payment/checkout/payment-form/StripePaymentForm.jsx +++ b/src/payment/checkout/payment-form/StripePaymentForm.jsx @@ -16,7 +16,6 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import CardHolderInformation from './CardHolderInformation'; import PlaceOrderButton from './PlaceOrderButton'; -import { PaymentProcessingModal } from '../../PaymentProcessingModal'; import SubscriptionSubmitButton from '../../../subscription/checkout/submit-button/SubscriptionSubmitButton'; import MonthlyBillingNotification from '../../../subscription/checkout/monthly-billing-notification/MonthlyBillingNotification'; @@ -186,18 +185,13 @@ const StripePaymentForm = ({ /> ) : ( - // Standard Purchase Flow - <> - {isProcessing && ( - - ) } - - + // Standard Purchase Flow + )} diff --git a/src/payment/data/__snapshots__/redux.test.js.snap b/src/payment/data/__snapshots__/redux.test.js.snap index e62d931f5..0c07dc638 100644 --- a/src/payment/data/__snapshots__/redux.test.js.snap +++ b/src/payment/data/__snapshots__/redux.test.js.snap @@ -6,6 +6,10 @@ Object { "isBasketProcessing": false, "loaded": false, "loading": true, + "paymentState": "checkout", + "paymentStatePolling": Object { + "keepPolling": false, + }, "products": Array [], "redirect": false, "submitting": false, @@ -28,6 +32,10 @@ Object { "isBasketProcessing": false, "loaded": false, "loading": true, + "paymentState": "checkout", + "paymentStatePolling": Object { + "keepPolling": false, + }, "products": Array [], "redirect": false, "submitting": false, @@ -50,6 +58,10 @@ Object { "isBasketProcessing": false, "loaded": false, "loading": true, + "paymentState": "checkout", + "paymentStatePolling": Object { + "keepPolling": false, + }, "products": Array [], "redirect": false, "submitting": false, diff --git a/src/payment/data/actions.js b/src/payment/data/actions.js index 52e5bf99f..a7cf502ae 100644 --- a/src/payment/data/actions.js +++ b/src/payment/data/actions.js @@ -20,6 +20,7 @@ export const fetchActiveOrder = createRoutine('FETCH_ACTIVE_ORDER'); export const addCoupon = createRoutine('ADD_COUPON'); export const removeCoupon = createRoutine('REMOVE_COUPON'); export const updateQuantity = createRoutine('UPDATE_QUANTITY'); +export const pollPaymentState = createRoutine('UPDATE_PAYMENT_STATE'); // Actions and their action creators export const BASKET_DATA_RECEIVED = 'BASKET_DATA_RECEIVED'; @@ -75,3 +76,10 @@ export const clientSecretDataReceived = clientSecret => ({ type: CLIENT_SECRET_DATA_RECEIVED, payload: clientSecret, }); + +export const PAYMENT_STATE_DATA_RECEIVED = 'PAYMENT_STATE_DATA_RECEIVED'; + +export const paymentStateDataReceived = paymentState => ({ + type: PAYMENT_STATE_DATA_RECEIVED, + payload: paymentState, +}); diff --git a/src/payment/data/constants.js b/src/payment/data/constants.js index 7c3d1aa6f..a487a2974 100644 --- a/src/payment/data/constants.js +++ b/src/payment/data/constants.js @@ -8,3 +8,58 @@ export const CERTIFICATE_TYPES = { VERIFIED: 'verified', CREDIT: 'credit', }; + +/** + * Payment State for async payment processing and UI Dialog Control. + * + * @see POLLING_PAYMENT_STATES + * + * @note: This enum is unique to Commerce Coordinator backend. + */ +export const PAYMENT_STATE = (((webserviceEnum = { + // The enum as the WS Sees it. + /** + * Draft (Checkout) Payment + */ + CHECKOUT: 'checkout', + /** + * Payment Complete + */ + COMPLETED: 'completed', + /** + * Server Side Payment Failure + */ + FAILED: 'failed', + /** + * Payment is Pending + */ + PENDING: 'pending', +}) => ({ + ...webserviceEnum, + + // Our Additions + + /** + * Default according to Redux initial state. (Should be an alias of an official value) + * + * @see PAYMENT_STATE.CHECKOUT + */ + DEFAULT: webserviceEnum.CHECKOUT, + + /** + * An HTTP Error has occurred between the client and server, this should not be sent over the line. + * + * @note **this is custom to the MFE**, thus a Symbol (which JSON usually skips in serialization) + */ + HTTP_ERROR: Symbol('mfe-only_http_error'), +}))()); + +/** + * An array of payment states that we intend to run polling against + * @type {(string|Symbol)[]} Values from PAYMENT_STATE + * @see PAYMENT_STATE + */ +export const POLLING_PAYMENT_STATES = [ + PAYMENT_STATE.PENDING, + PAYMENT_STATE.HTTP_ERROR, +]; diff --git a/src/payment/data/reducers.js b/src/payment/data/reducers.js index a30ae99f8..112918b83 100644 --- a/src/payment/data/reducers.js +++ b/src/payment/data/reducers.js @@ -8,15 +8,35 @@ import { CLIENT_SECRET_DATA_RECEIVED, CLIENT_SECRET_PROCESSING, MICROFORM_STATUS, + PAYMENT_STATE_DATA_RECEIVED, fetchBasket, submitPayment, fetchCaptureKey, fetchClientSecret, fetchActiveOrder, + pollPaymentState, } from './actions'; import { DEFAULT_STATUS } from '../checkout/payment-form/flex-microform/constants'; +import { PAYMENT_STATE, POLLING_PAYMENT_STATES } from './constants'; +import { chainReducers } from './utils'; + +/** + * Internal State for the Payment State Polling Mechanism. Used entirely for Project Theseus. + */ +const paymentStatePollingInitialState = { + /** + * @see paymentProcessStatusIsPollingSelector + */ + keepPolling: false, +}; +/** + * Initial basket state + * + * Certain of these values are reused for Theseus, information o how they are remapped can be found in the + * System Manual. + */ const basketInitialState = { loading: true, loaded: false, @@ -24,6 +44,10 @@ const basketInitialState = { redirect: false, isBasketProcessing: false, products: [], + /** Modified by both getActiveOrder and paymentStatePolling */ + paymentState: PAYMENT_STATE.DEFAULT, + /** state specific to paymentStatePolling */ + paymentStatePolling: paymentStatePollingInitialState, }; const basket = (state = basketInitialState, action = null) => { @@ -122,8 +146,51 @@ const clientSecret = (state = clientSecretInitialState, action = null) => { return state; }; +const paymentState = (state = basketInitialState, action = null) => { + const shouldPoll = (payState) => POLLING_PAYMENT_STATES.includes(payState); + + if (action !== null && action !== undefined) { + switch (action.type) { + case pollPaymentState.TRIGGER: + return { + ...state, + paymentStatePolling: { + ...state.paymentStatePolling, + keepPolling: shouldPoll(state.paymentState), + }, + }; + + case pollPaymentState.FULFILL: + return { + ...state, + paymentStatePolling: { + ...state.paymentStatePolling, + keepPolling: false, + }, + }; + + case PAYMENT_STATE_DATA_RECEIVED: + return { + ...state, + paymentState: action.payload.state, + paymentStatePolling: { + ...state.paymentStatePolling, + keepPolling: shouldPoll(action.payload.state), + // ...action.payload, // debugging + }, + }; + + default: + } + } + return state; +}; + const reducer = combineReducers({ - basket, + basket: chainReducers([ + basket, + paymentState, + ]), captureKey, clientSecret, }); diff --git a/src/payment/data/redux.test.js b/src/payment/data/redux.test.js index 65e733dfb..262a621e2 100644 --- a/src/payment/data/redux.test.js +++ b/src/payment/data/redux.test.js @@ -8,9 +8,12 @@ import { submitPayment, fetchBasket, fetchActiveOrder, + pollPaymentState, + PAYMENT_STATE_DATA_RECEIVED, } from './actions'; import { currencyDisclaimerSelector, paymentSelector } from './selectors'; import { localizedCurrencySelector } from './utils'; +import { PAYMENT_STATE } from './constants'; jest.mock('universal-cookie', () => { class MockCookies { @@ -109,7 +112,10 @@ describe('redux tests', () => { isBasketProcessing: false, isEmpty: false, isRedirect: false, - paymentState: '', + paymentState: PAYMENT_STATE.DEFAULT, + paymentStatePolling: { + keepPolling: false, + }, }); }); @@ -130,7 +136,10 @@ describe('redux tests', () => { isBasketProcessing: false, isEmpty: false, isRedirect: true, // this is also now true. - paymentState: '', + paymentState: PAYMENT_STATE.DEFAULT, + paymentStatePolling: { + keepPolling: false, + }, }); }); }); @@ -220,5 +229,41 @@ describe('redux tests', () => { }); }); }); + + describe('pollPaymentState actions', () => { + it('Round Trip', () => { + const triggerStore = createStore( + combineReducers({ + payment: reducer, + }), + { + payment: + { + basket: { + foo: 'bar', + paymentState: PAYMENT_STATE.PENDING, + basketId: 7, + payments: [ + { paymentNumber: 7 }, + ], + isBasketProcessing: false, + }, + }, + }, + ); + + triggerStore.dispatch(pollPaymentState()); + expect(triggerStore.getState().payment.basket.paymentStatePolling.keepPolling).toBe(true); + expect(triggerStore.getState().payment.basket.paymentState).toBe(PAYMENT_STATE.PENDING); + + triggerStore.dispatch({ type: PAYMENT_STATE_DATA_RECEIVED, payload: { state: PAYMENT_STATE.COMPLETED } }); + expect(triggerStore.getState().payment.basket.paymentStatePolling.keepPolling).toBe(false); + expect(triggerStore.getState().payment.basket.paymentState).toBe(PAYMENT_STATE.COMPLETED); + + triggerStore.dispatch(pollPaymentState.fulfill()); + expect(triggerStore.getState().payment.basket.paymentStatePolling.keepPolling).toBe(false); + expect(triggerStore.getState().payment.basket.paymentState === PAYMENT_STATE.PENDING).toBe(false); + }); + }); }); }); diff --git a/src/payment/data/sagas.js b/src/payment/data/sagas.js index 9be2eb8a0..d961e3cdd 100644 --- a/src/payment/data/sagas.js +++ b/src/payment/data/sagas.js @@ -2,7 +2,8 @@ import { call, put, takeEvery, select, delay, } from 'redux-saga/effects'; import { stopSubmit } from 'redux-form'; -import { getReduxFormValidationErrors } from './utils'; +import { getConfig } from '@edx/frontend-platform'; +import { getReduxFormValidationErrors, MINS_AS_MS, SECS_AS_MS } from './utils'; import { MESSAGE_TYPES } from '../../feedback/data/constants'; // Actions @@ -24,6 +25,8 @@ import { fetchCaptureKey, clientSecretProcessing, fetchClientSecret, + paymentStateDataReceived, + pollPaymentState, } from './actions'; import { STATUS_LOADING } from '../checkout/payment-form/flex-microform/constants'; @@ -37,6 +40,8 @@ import { checkoutWithToken } from '../payment-methods/cybersource'; import { checkout as checkoutPaypal } from '../payment-methods/paypal'; import { checkout as checkoutApplePay } from '../payment-methods/apple-pay'; import { checkout as checkoutStripe } from '../payment-methods/stripe'; +import { paymentProcessStatusShouldRunSelector } from './selectors'; +import { PAYMENT_STATE } from './constants'; export const paymentMethods = { cybersource: checkoutWithToken, @@ -139,12 +144,15 @@ export function* handleFetchActiveOrder() { } finally { yield put(basketProcessing(false)); // we are done modifying the basket yield put(fetchActiveOrder.fulfill()); // mark the basket as finished loading + if (yield select((state) => paymentProcessStatusShouldRunSelector(state))) { + yield put(pollPaymentState()); + } } } export function* handleCaptureKeyTimeout() { // Start at the 12min mark to leave 1 min of buffer on the 15min timeout - yield delay(12 * 60 * 1000); + yield delay(MINS_AS_MS(12)); yield call( handleMessages, [{ @@ -155,7 +163,7 @@ export function* handleCaptureKeyTimeout() { window.location.search, ); - yield delay(1 * 60 * 1000); + yield delay(MINS_AS_MS(1)); yield call( handleMessages, [{ @@ -166,7 +174,7 @@ export function* handleCaptureKeyTimeout() { window.location.search, ); - yield delay(1 * 60 * 1000); + yield delay(MINS_AS_MS(1)); yield put(clearMessages()); yield put(fetchCaptureKey()); } @@ -263,10 +271,15 @@ export function* handleSubmitPayment({ payload }) { yield put(basketProcessing(true)); yield put(clearMessages()); // Don't leave messages floating on the page after clicking submit yield put(submitPayment.request()); + const paymentMethodCheckout = paymentMethods[method]; const basket = yield select(state => ({ ...state.payment.basket })); yield call(paymentMethodCheckout, basket, paymentArgs); yield put(submitPayment.success()); + + if (yield select((state) => paymentProcessStatusShouldRunSelector(state))) { + yield put(pollPaymentState()); + } } catch (error) { // Do not handle errors on user aborted actions if (!error.aborted) { @@ -290,6 +303,53 @@ export function* handleSubmitPayment({ payload }) { } } +/** + * Redux handler for payment status polling and updates + * + * Note: + * - This handler/worker loops until it is told to stop. via a state property (keepPolling), or a fatal state. + */ +export function* handlePaymentState() { + const DEFAULT_DELAY_SECS = 5; + let keepPolling = true; + + // noinspection JSUnresolvedReference + const delaySecs = getConfig().PAYMENT_STATE_POLLING_DELAY_SECS || DEFAULT_DELAY_SECS; + + // NOTE: We may want to have a max errors check and have a fail state if there's a bad connection or something. + + while (keepPolling) { + try { + const basketId = yield select(state => state.payment.basket.basketId); + const paymentNumber = yield select(state => (state.payment.basket.payments.length === 0 + ? null : state.payment.basket.payments[0].paymentNumber)); + + if (!basketId || !paymentNumber) { + // This shouldn't happen. + // I don't think we need to banner... shouldn't our parent calls recover? (They invoke this) + keepPolling = false; + yield put(pollPaymentState.fulfill()); + return; + } + + const result = yield call(PaymentApiService.getCurrentPaymentState, paymentNumber, basketId); + yield put(paymentStateDataReceived(result)); + + if (!(yield select(state => state.payment.basket.paymentStatePolling.keepPolling))) { + keepPolling = false; + yield put(pollPaymentState.fulfill()); + } else { + yield delay(SECS_AS_MS(delaySecs)); + } + } catch (error) { + // We dont quit on error. + // yield call(handleErrors, error, true); + yield put(paymentStateDataReceived({ state: PAYMENT_STATE.HTTP_ERROR })); + yield delay(SECS_AS_MS(delaySecs)); + } + } +} + export default function* saga() { yield takeEvery(fetchCaptureKey.TRIGGER, handleFetchCaptureKey); yield takeEvery(CAPTURE_KEY_START_TIMEOUT, handleCaptureKeyTimeout); @@ -300,4 +360,5 @@ export default function* saga() { yield takeEvery(removeCoupon.TRIGGER, handleRemoveCoupon); yield takeEvery(updateQuantity.TRIGGER, handleUpdateQuantity); yield takeEvery(submitPayment.TRIGGER, handleSubmitPayment); + yield takeEvery(pollPaymentState.TRIGGER, handlePaymentState); } diff --git a/src/payment/data/sagas.test.js b/src/payment/data/sagas.test.js index 65d59ac74..fd3634e91 100644 --- a/src/payment/data/sagas.test.js +++ b/src/payment/data/sagas.test.js @@ -1,11 +1,13 @@ import { getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { runSaga } from 'redux-saga'; import { takeEvery } from 'redux-saga/effects'; import { stopSubmit } from 'redux-form'; import { Factory } from 'rosie'; + import paymentSaga, { handleFetchBasket, handleFetchActiveOrder, @@ -16,6 +18,7 @@ import paymentSaga, { handleFetchCaptureKey, handleCaptureKeyTimeout, handleFetchClientSecret, + handlePaymentState, } from './sagas'; import { transformResults } from './utils'; import { @@ -30,12 +33,14 @@ import { submitPayment, CAPTURE_KEY_START_TIMEOUT, fetchClientSecret, + pollPaymentState, } from './actions'; import { clearMessages, MESSAGE_TYPES, addMessage } from '../../feedback'; import '../__factories__/basket.factory'; import * as cybersourceService from '../payment-methods/cybersource'; +import { PAYMENT_STATE } from './constants'; jest.mock('@edx/frontend-platform/auth'); jest.mock('@edx/frontend-platform/logging'); @@ -48,7 +53,7 @@ getAuthenticatedHttpClient.mockReturnValue(axios); const BASKET_API_ENDPOINT = `${getConfig().ECOMMERCE_BASE_URL}/bff/payment/v0/payment/`; // const CC_ORDER_API_ENDPOINT = `${getConfig().COMMERCE_COORDINATOR_BASE_URL}/frontend-app-payment/order/`; -const CC_ORDER_API_ENDPOINT = `${process.env.COMMERCE_COORDINATOR_BASE_URL}/frontend-app-payment/order/`; +const CC_ORDER_API_ENDPOINT = `${process.env.COMMERCE_COORDINATOR_BASE_URL}/frontend-app-payment/order/active/`; const DISCOUNT_API_ENDPOINT = `${getConfig().LMS_BASE_URL}/api/discounts/course/`; const COUPON_API_ENDPOINT = `${getConfig().ECOMMERCE_BASE_URL}/bff/payment/v0/vouchers/`; const QUANTITY_API_ENDPOINT = `${getConfig().ECOMMERCE_BASE_URL}/bff/payment/v0/quantity/`; @@ -617,6 +622,126 @@ describe('saga tests', () => { ); }); + describe('Stripe Payments: should successfully call stripe checkout method ', () => { + let nativeGlobalLocation; + + beforeEach(() => { + const { STRIPE_RESPONSE_URL } = process.env; + axiosMock.onPost(STRIPE_RESPONSE_URL).reply(200, { redirect_url: 'http://some-silly-nonsense.com' }); + + // We have to override this because we cant pass a location setter function to Stripes Checkout + // We reset it back after each run so future tests do not explode. + nativeGlobalLocation = global.location; + delete global.location; + global.location = jest.fn(); + }); + + afterEach(() => { + global.location = nativeGlobalLocation; + }); + + const mockSetLocation = jest.fn(); + const context = jest.fn(); + context.authenticatedUser = { email: 'example@example.com' }; + + const stripeArgs = { + payload: { + method: 'stripe', + meh: 'wut', + values: { + firstName: 'John', + lastName: 'Jingleheimer-schmidt', + address: '123 Anytown Way', + unit: '7', + city: 'AnyCity', + country: 'US', + state: 'AS', + postalCode: 11111, + organization: null, + purchasedForOrganization: false, + }, + stripe: { + updatePaymentIntent: jest.fn(() => Promise.resolve({ + paymentIntent: { + id: 'pi_3LsftNIadiFyUl1x2TWxaADZ', + }, + })), + }, + skus: '8CF08E5', + elements: jest.fn(), + context, + mockSetLocation, + }, + }; + + it('Processing Payment State', async () => { + try { + await runSaga( + { + getState: () => ({ + payment: { + basket: { + foo: 'bar', + paymentState: PAYMENT_STATE.PENDING, + basketId: 7, + payments: [ + { paymentNumber: 7 }, + ], + isBasketProcessing: false, + }, + }, + }), + ...sagaOptions, + }, + handleSubmitPayment, + stripeArgs, + ).toPromise(); + } catch (e) {} // eslint-disable-line no-empty + + expect(dispatched).toEqual([ + basketProcessing(true), + clearMessages(), + submitPayment.request(), + submitPayment.success(), + pollPaymentState.trigger(), + basketProcessing(false), + submitPayment.fulfill(), + ]); + expect(caughtErrors).toEqual([]); + }); + + it('With normal basket state', async () => { + try { + await runSaga( + { + getState: () => ({ + payment: { + basket: { + foo: 'bar', + isBasketProcessing: false, + }, + }, + }), + ...sagaOptions, + }, + handleSubmitPayment, + stripeArgs, + ).toPromise(); + } catch (e) {} // eslint-disable-line no-empty + + expect(dispatched).toEqual([ + basketProcessing(true), + clearMessages(), + submitPayment.request(), + // + submitPayment.success(), + basketProcessing(false), + submitPayment.fulfill(), + ]); + expect(caughtErrors).toEqual([]); + }); + }); + it('should bail on error handling if the error was aborted', async () => { const error = new Error(); error.aborted = true; @@ -802,6 +927,7 @@ describe('saga tests', () => { expect(gen.next().value).toEqual(takeEvery(removeCoupon.TRIGGER, handleRemoveCoupon)); expect(gen.next().value).toEqual(takeEvery(updateQuantity.TRIGGER, handleUpdateQuantity)); expect(gen.next().value).toEqual(takeEvery(submitPayment.TRIGGER, handleSubmitPayment)); + expect(gen.next().value).toEqual(takeEvery(pollPaymentState.TRIGGER, handlePaymentState)); // If you find yourself adding something here, there are probably more tests to write! diff --git a/src/payment/data/selectors.js b/src/payment/data/selectors.js index d17835159..4d24f2545 100644 --- a/src/payment/data/selectors.js +++ b/src/payment/data/selectors.js @@ -2,6 +2,7 @@ import { getQueryParameters } from '@edx/frontend-platform'; import { createSelector } from 'reselect'; import { localizedCurrencySelector } from './utils'; import { DEFAULT_STATUS } from '../checkout/payment-form/flex-microform/constants'; +import { POLLING_PAYMENT_STATES } from './constants'; export const storeName = 'payment'; @@ -76,6 +77,27 @@ export const updateClientSecretSelector = createSelector( }), ); -// TODO: We may want to store the server side enum value rather than just a boolean. As such the dialog was coded this -// way. And we translate. -export const paymentProcessStatusSelector = state => (state[storeName].basket.isBasketProcessing ? 'pending' : 'not'); +/** + * Get the current payment processing state + * @see PAYMENT_STATE + * @param {*} state global redux state + * @return {string} a valid value from PAYMENT_STATE + */ +export const paymentProcessStatusSelector = state => (state[storeName].basket.paymentState); + +/** + * Determine if the current state warrants a run of the Payment Sate Polling Mechanism + * @param state + * @return boolean + * @see POLLING_PAYMENT_STATES + */ +export const paymentProcessStatusShouldRunSelector = state => ( + POLLING_PAYMENT_STATES.includes(paymentProcessStatusSelector(state)) +); + +/** + * Selector to see if the Payment Status Polling system is running. + * @param state global redux state + * @return {boolean} + */ +export const paymentProcessStatusIsPollingSelector = state => (state[storeName].basket.paymentStatePolling.keepPolling); diff --git a/src/payment/data/service.js b/src/payment/data/service.js index a54619839..bc6cc17a4 100644 --- a/src/payment/data/service.js +++ b/src/payment/data/service.js @@ -7,7 +7,6 @@ import { transformResults } from './utils'; ensureConfig([ 'ECOMMERCE_BASE_URL', 'LMS_BASE_URL', - 'COMMERCE_COORDINATOR_BASE_URL', ], 'payment API service'); function handleBasketApiError(requestError) { @@ -53,7 +52,7 @@ export async function getBasket(discountJwt) { export async function getActiveOrder() { const { data } = await getAuthenticatedHttpClient() // .get(`${getConfig().COMMERCE_COORDINATOR_BASE_URL}/frontend-app-payment/order/`) - .get(`${process.env.COMMERCE_COORDINATOR_BASE_URL}/frontend-app-payment/order/`) + .get(`${process.env.COMMERCE_COORDINATOR_BASE_URL}/frontend-app-payment/order/active/`) .catch(handleBasketApiError); return transformResults(data); } @@ -94,3 +93,19 @@ export async function getDiscountData(courseKey) { ); return data; } + +export async function getCurrentPaymentState(paymentNumber, basketId) { + const { data } = await getAuthenticatedHttpClient() + .get( + `${process.env.COMMERCE_COORDINATOR_BASE_URL}/frontend-app-payment/payment`, + { + params: + { + payment_number: paymentNumber, + order_uuid: basketId, + }, + }, + ) + .catch(handleBasketApiError); + return data; +} diff --git a/src/payment/data/utils.js b/src/payment/data/utils.js index 84c052b08..107b7499f 100644 --- a/src/payment/data/utils.js +++ b/src/payment/data/utils.js @@ -228,3 +228,48 @@ export const getPropsToRemoveFractionZeroDigits = ({ price, shouldRemoveFraction } return props; }; + +/** + * Convert Minutes to Milliseconds + * @param x Minutes + * @return {number} Milliseconds + */ +export const MINS_AS_MS = (x = 0) => x * 60 * 1000; + +/** + * Convert Seconds to Milliseconds + * @param x Seconds + * @return {number} Milliseconds + */ +export const SECS_AS_MS = (x = 0) => x * 1000; + +/** + * Chain Reducers allows more than one reducer to alter state before the store modifications are accepted. + * + * Literally, if we only have one reducer, we return it (wrapping would be useless), + * otherwise we reduce our reducers. + * + * If you didn't provide any, we return undefined... and i bet React/Redux will be annoyed. + * + * @param reducers Reducers to loop through sharing state changes from the last in the list as input. + * @return A common reducer, so Redux allows independent ones to share state slots + */ +export const chainReducers = (reducers) => { + switch (reducers.length) { + case 0: + return undefined; + case 1: + return reducers[0]; + default: /* No-op, lets continue execution */ break; + } + + // Using a function so someone with a debugger doesn't see infinite anonymous functions + return function _wrappedSerialChainReducers(initialState, action) { + // This loops through the array of reducers, by reducing the array. + // The return of the inner reducerFn becomes the lastState for the next. + return reducers.reduce( + (lastState, reducerFn) => reducerFn(lastState, action), + initialState, + ); + }; +}; diff --git a/src/payment/data/utils.test.js b/src/payment/data/utils.test.js index 376501c19..49bd1aff4 100644 --- a/src/payment/data/utils.test.js +++ b/src/payment/data/utils.test.js @@ -14,6 +14,9 @@ import { getOrderType, transformResults, getPropsToRemoveFractionZeroDigits, + SECS_AS_MS, + MINS_AS_MS, + chainReducers, } from './utils'; describe('modifyObjectKeys', () => { @@ -253,3 +256,117 @@ describe('getPropsToRemoveFractionZeroDigits', () => { expect(getPropsToRemoveFractionZeroDigits({ price: 79.43, shouldRemoveFractionZeroDigits: false })).toEqual({ }); }); }); + +describe('Time Functions', () => { + const tests = [ + /* eslint-disable no-multi-spaces */ // Formatted for tabular layout + // Functional Tests + { fn: SECS_AS_MS, in: 10, out: 10000 }, + { fn: SECS_AS_MS, in: 1, out: 1000 }, + { fn: SECS_AS_MS, in: 0, out: 0 }, + { fn: MINS_AS_MS, in: 10, out: 600000 }, + { fn: MINS_AS_MS, in: 1, out: 60000 }, + { fn: MINS_AS_MS, in: 0, out: 0 }, + + // Comparative Result Tests (Since these are pure & mathematical, they should never fail to run, but be wrong.) + { name: 'SECS eq MIN Conversions Match', in: MINS_AS_MS(0), out: SECS_AS_MS(0) }, + { name: 'SECS eq MIN Conversions Match', in: MINS_AS_MS(2), out: SECS_AS_MS(120) }, + { name: 'SECS eq MIN Conversions Match', in: MINS_AS_MS(500), out: SECS_AS_MS(30000) }, + // intentionally absurd value + { name: 'SECS eq MIN Conversions Match', in: MINS_AS_MS(7217), out: SECS_AS_MS(433020) }, + /* eslint-enable no-multi-spaces */ + ]; + + for (let i = 0, testPlan = tests[i]; i < tests.length; i++, testPlan = tests[i]) { + const functionalTest = testPlan.fn !== undefined; + const testBaseName = functionalTest ? testPlan.fn.name : testPlan.name; + + it(`${testBaseName} In: ${testPlan.in} Out: ${testPlan.out}`, () => { + if (functionalTest) { + expect(testPlan.fn(testPlan.in)).toEqual(testPlan.out); + } else { + expect(testPlan.in).toEqual(testPlan.out); + } + }); + } +}); + +describe('chainReducers([reducers])', () => { + /* Test Constants */ + const DEFAULT_VALUE = 'x'; + const SET_VALUE = 'set'; + + /* Functors/Data Generators */ + const addAction = (reducer, testAction) => (state, action) => reducer.call(null, state, action || testAction); + const alphaReducer = (val) => (stateX) => ({ ...stateX, myval: val }); + const testAction = (x = 'test_action') => ({ type: x }); + + /* Canned Tests */ + const tests = [ + { + first: alphaReducer('a'), + second: alphaReducer('b'), + action: testAction(), + value: 'b', + msg: 'Should succeed.', + }, + { + first: alphaReducer('b'), + second: alphaReducer('a'), + action: testAction(), + value: 'a', + msg: 'Should succeed. (tests ordering by running the last test in reverse and expecting the opposite value)', + }, + ]; + + for (let i = 0, testPlan = tests[i]; i < tests.length; i++, testPlan = tests[i]) { + it(`chain reducers return ${testPlan.value}, ${testPlan.msg}`, () => { + const resultingState = addAction(chainReducers([testPlan.first, testPlan.second]), testPlan.action); + + expect(resultingState({ myval: DEFAULT_VALUE }, null).myval).toEqual(testPlan.value); + }); + } + + /* More Complex Stuff */ + it('chain reducers should accumulate each others state', () => { + const reducer1 = (state) => ({ ...state, first_reducer_value: SET_VALUE }); + const reducer2 = (state) => ({ ...state, second_reducer_value: SET_VALUE }); + + const action = testAction(); + + const resultingState = addAction(chainReducers([reducer1, reducer2]), action); + const resultingStateFlipped = addAction(chainReducers([reducer2, reducer1]), action); + + const firstStateResult = resultingState({ myval: DEFAULT_VALUE }, null); + const secondStateResult = resultingStateFlipped({ myval: DEFAULT_VALUE }, null); + + expect(firstStateResult.myval).toEqual(DEFAULT_VALUE); + expect(firstStateResult.first_reducer_value).toEqual(SET_VALUE); + expect(firstStateResult.second_reducer_value).toEqual(SET_VALUE); + + expect(secondStateResult.myval).toEqual(DEFAULT_VALUE); + expect(secondStateResult.first_reducer_value).toEqual(SET_VALUE); + expect(secondStateResult.second_reducer_value).toEqual(SET_VALUE); + }); + + it('chain reducers should fall through if actions arent responded to', () => { + const reducer1 = function _reducer1(state, action) { + if (action.type !== 'reducer1') { return ({ ...state }); } + return ({ ...state, first_reducer_value: SET_VALUE }); + }; + const reducer2 = function _reducer2(state, action) { + if (action.type !== 'reducer2') { return ({ ...state }); } + return ({ ...state, first_reducer_value: SET_VALUE }); + }; + + const action = testAction('reducer1'); + + const resultingState = addAction(chainReducers([reducer1, reducer2]), action); + + const firstStateResult = resultingState({ myval: DEFAULT_VALUE }, null); + + expect(firstStateResult.myval).toEqual(DEFAULT_VALUE); + expect(firstStateResult.first_reducer_value).toEqual(SET_VALUE); + expect(firstStateResult.second_reducer_value).toEqual(undefined); + }); +});