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