Skip to content

Commit

Permalink
feat: THES-105: Payment State Polling (#771)
Browse files Browse the repository at this point in the history
* feat: THES-105: Payment State Polling

Payment State polling on Submit Payment and Get Active Order, with a normally non-closeable Dialog.

* fix: Advisories permitted for the moment

This spawned REV-3598.

* fix: Removal of  `PAYMENT_STATE.PROCESSING`, this state is internal to the Titan backend.

* fix: Changes from PR Review
  • Loading branch information
grmartin committed Jul 3, 2023
1 parent 0abe5c1 commit b655061
Show file tree
Hide file tree
Showing 16 changed files with 656 additions and 51 deletions.
4 changes: 3 additions & 1 deletion audit-ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions src/payment/PaymentPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -96,6 +97,7 @@ class PaymentPage extends React.Component {
/>
</h1>
<div className="col-md-5 pr-md-5 col-basket-summary">
<PaymentProcessingModal />
<Cart
isNumEnrolledExperiment={isNumEnrolledExperiment}
REV1045Experiment={REV1045Experiment}
Expand Down
49 changes: 35 additions & 14 deletions src/payment/PaymentProcessingModal.jsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,55 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';

import {
ModalDialog, Spinner,
} from '@edx/paragon';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ModalDialog, Spinner } from '@edx/paragon';
import { connect, useSelector } from 'react-redux';
import { injectIntl, useIntl } from '@edx/frontend-platform/i18n';

import messages from './PaymentProcessingModal.messages';
import { paymentProcessStatusSelector } from './data/selectors';
import { paymentProcessStatusIsPollingSelector, paymentProcessStatusSelector } from './data/selectors';
import { POLLING_PAYMENT_STATES } from './data/constants';

/**
* PaymentProcessingModal
*
* This modal is controlled primarily by some Redux selectors.
*
* Controls Visibility: `paymentProcessStatusSelector`, `paymentProcessStatusIsPollingSelector`
* @see paymentProcessStatusSelector
* @see paymentProcessStatusIsPollingSelector
*
* Primary Event: `pollPaymentState`
* @see pollPaymentState
*
* If you wish to perform an action as this dialog closes, please register for the pollPaymentState fulfill event.
*/
export const PaymentProcessingModal = () => {
/**
* 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 (
<ModalDialog
title="Your Payment is Processing"
isOpen={isOpen}
onClose={() => {}}
onClose={() => { /* Noop, @see pollPaymentState fulfill */ }}
hasCloseButton={false}
isFullscreenOnMobile={false}
>
Expand All @@ -51,4 +72,4 @@ export const PaymentProcessingModal = () => {
);
};

export default PaymentProcessingModal;
export default connect()(injectIntl(PaymentProcessingModal));
33 changes: 23 additions & 10 deletions src/payment/PaymentProcessingModal.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,28 @@ 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: {
loading: true,
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: '' },
},
Expand All @@ -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
Expand Down Expand Up @@ -68,18 +78,21 @@ describe('<PaymentProcessingModal />', () => {
},
});

/* 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((
<IntlProvider locale="en">
Expand Down
20 changes: 7 additions & 13 deletions src/payment/checkout/payment-form/StripePaymentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -186,18 +185,13 @@ const StripePaymentForm = ({
/>
</>
) : (
// Standard Purchase Flow
<>
{isProcessing && (
<PaymentProcessingModal />
) }
<PlaceOrderButton
onSubmitButtonClick={onSubmitButtonClick}
showLoadingButton={showLoadingButton}
disabled={submitting}
isProcessing={isProcessing}
/>
</>
// Standard Purchase Flow
<PlaceOrderButton
onSubmitButtonClick={onSubmitButtonClick}
showLoadingButton={showLoadingButton}
disabled={submitting}
isProcessing={isProcessing}
/>
)}

</form>
Expand Down
12 changes: 12 additions & 0 deletions src/payment/data/__snapshots__/redux.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Object {
"isBasketProcessing": false,
"loaded": false,
"loading": true,
"paymentState": "checkout",
"paymentStatePolling": Object {
"keepPolling": false,
},
"products": Array [],
"redirect": false,
"submitting": false,
Expand All @@ -28,6 +32,10 @@ Object {
"isBasketProcessing": false,
"loaded": false,
"loading": true,
"paymentState": "checkout",
"paymentStatePolling": Object {
"keepPolling": false,
},
"products": Array [],
"redirect": false,
"submitting": false,
Expand All @@ -50,6 +58,10 @@ Object {
"isBasketProcessing": false,
"loaded": false,
"loading": true,
"paymentState": "checkout",
"paymentStatePolling": Object {
"keepPolling": false,
},
"products": Array [],
"redirect": false,
"submitting": false,
Expand Down
8 changes: 8 additions & 0 deletions src/payment/data/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});
55 changes: 55 additions & 0 deletions src/payment/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Loading

0 comments on commit b655061

Please sign in to comment.