From d5d786049e9081c5fbe6175151d0c9f798001fbe Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Fri, 2 Apr 2021 09:33:34 +0300 Subject: [PATCH] feat(api): stripe checkout integration (#41666) * feat: add api stripe checkout integration Co-authored-by: Oliver Eyton-Williams --- api-server/src/server/boot/donate.js | 54 ++++++++++++++++- .../middlewares/request-authorization.js | 4 +- client/i18n/locales/english/translations.json | 1 + .../components/Donation/DonateCompletion.js | 18 ++++-- client/src/components/Donation/DonateForm.js | 60 ++++++++----------- client/src/redux/donation-saga.js | 27 ++++++++- client/src/redux/index.js | 8 +++ client/src/utils/ajax.js | 4 ++ 8 files changed, 134 insertions(+), 42 deletions(-) diff --git a/api-server/src/server/boot/donate.js b/api-server/src/server/boot/donate.js index 21e5aa4f29..abf52df6ef 100644 --- a/api-server/src/server/boot/donate.js +++ b/api-server/src/server/boot/donate.js @@ -11,9 +11,13 @@ import { import { durationKeysConfig, donationOneTimeConfig, - donationSubscriptionConfig + donationSubscriptionConfig, + durationsConfig, + onetimeSKUConfig, + donationUrls } from '../../../../config/donation-settings'; import keys from '../../../../config/secrets'; +import { deploymentEnv } from '../../../../config/env'; const log = debug('fcc:boot:donate'); @@ -246,6 +250,53 @@ export default function donateBoot(app, done) { }); } + async function createStripeSession(req, res) { + const { + body, + body: { donationAmount, donationDuration } + } = req; + if (!body) { + return res + .status(500) + .send({ type: 'danger', message: 'Request has not completed.' }); + } + const isSubscription = donationDuration !== 'onetime'; + const getSKUId = () => { + const { id } = onetimeSKUConfig[deploymentEnv || 'staging'].find( + skuConfig => skuConfig.amount === `${donationAmount}` + ); + return id; + }; + const price = isSubscription + ? `${durationsConfig[donationDuration]}-donation-${donationAmount}` + : getSKUId(); + + /* eslint-disable camelcase */ + try { + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price, + quantity: 1 + } + ], + metadata: { ...body }, + mode: isSubscription ? 'subscription' : 'payment', + success_url: donationUrls.successUrl, + cancel_url: donationUrls.cancelUrl + }); + /* eslint-enable camelcase */ + return res.status(200).json({ id: session.id }); + } catch (err) { + log(err.message); + return res.status(500).send({ + type: 'danger', + message: 'Something went wrong.' + }); + } + } + function updatePaypal(req, res) { const { headers, body } = req; return Promise.resolve(req) @@ -284,6 +335,7 @@ export default function donateBoot(app, done) { done(); } else { api.post('/charge-stripe', createStripeDonation); + api.post('/create-stripe-session', createStripeSession); api.post('/add-donation', addDonation); hooks.post('/update-paypal', updatePaypal); donateRouter.use('/donate', api); diff --git a/api-server/src/server/middlewares/request-authorization.js b/api-server/src/server/middlewares/request-authorization.js index a602d6d373..28b6367773 100644 --- a/api-server/src/server/middlewares/request-authorization.js +++ b/api-server/src/server/middlewares/request-authorization.js @@ -24,6 +24,7 @@ const statusRE = /^\/status\/ping$/; const unsubscribedRE = /^\/unsubscribed\//; const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//; const updateHooksRE = /^\/hooks\/update-paypal$|^\/hooks\/update-stripe$/; +const createStripeSession = /^\/donate\/create-stripe-session/; // note: this would be replaced by webhooks later const donateRE = /^\/donate\/charge-stripe$/; @@ -41,7 +42,8 @@ const _pathsAllowedREs = [ unsubscribedRE, unsubscribeRE, updateHooksRE, - donateRE + donateRE, + createStripeSession ]; export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) { diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index f6a7bdacf0..772921a8d2 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -289,6 +289,7 @@ "donate": { "title": "Support our nonprofit", "processing": "We are processing your donation.", + "redirecting": "Redirecting...", "thanks": "Thanks for donating", "thank-you": "Thank you for being a supporter.", "thank-you-2": "Thank you for being a supporter of freeCodeCamp. You currently have a recurring donation.", diff --git a/client/src/components/Donation/DonateCompletion.js b/client/src/components/Donation/DonateCompletion.js index 148a22d1c3..107f74c570 100644 --- a/client/src/components/Donation/DonateCompletion.js +++ b/client/src/components/Donation/DonateCompletion.js @@ -9,15 +9,25 @@ import './Donation.css'; const propTypes = { error: PropTypes.string, processing: PropTypes.bool, + redirecting: PropTypes.bool, reset: PropTypes.func.isRequired, success: PropTypes.bool }; -function DonateCompletion({ processing, reset, success, error = null }) { +function DonateCompletion({ + processing, + reset, + success, + redirecting, + error = null +}) { /* eslint-disable no-nested-ternary */ const { t } = useTranslation(); - const style = processing ? 'info' : success ? 'success' : 'danger'; - const heading = processing + const style = + processing || redirecting ? 'info' : success ? 'success' : 'danger'; + const heading = redirecting + ? `${t('donate.redirecting')}` + : processing ? `${t('donate.processing')}` : success ? `${t('donate.thank-you')}` @@ -28,7 +38,7 @@ function DonateCompletion({ processing, reset, success, error = null }) { {heading}
- {processing && ( + {(processing || redirecting) && ( num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); const propTypes = { addDonation: PropTypes.func, + createStripeSession: PropTypes.func, defaultTheme: PropTypes.string, donationFormState: PropTypes.object, email: PropTypes.string, @@ -84,7 +85,8 @@ const mapDispatchToProps = { addDonation, navigate, postChargeStripe, - updateDonationFormState + updateDonationFormState, + createStripeSession }; class DonateForm extends Component { @@ -215,39 +217,26 @@ class DonateForm extends Component { } async handleStripeCheckoutRedirect(e, paymentMethod) { - const { stripe } = this.state; - const { donationAmount, donationDuration } = this.state; + e.preventDefault(); + const { stripe, donationAmount, donationDuration } = this.state; + const { handleProcessing, email } = this.props; - this.props.handleProcessing( + handleProcessing( donationDuration, donationAmount, `stripe (${paymentMethod}) button click` ); - const isOneTime = donationDuration === 'onetime'; - const getSKUId = () => { - const { id } = onetimeSKUConfig[deploymentEnv || 'staging'].find( - skuConfig => skuConfig.amount === `${donationAmount}` - ); - return id; - }; - - e.preventDefault(); - const item = isOneTime - ? { - sku: getSKUId(), - quantity: 1 - } - : { - plan: `${this.durations[donationDuration]}-donation-${donationAmount}`, - quantity: 1 - }; - const { error } = await stripe.redirectToCheckout({ - items: [item], - successUrl: donationUrls.successUrl, - cancelUrl: donationUrls.cancelUrl + this.props.createStripeSession({ + stripe, + data: { + donationAmount, + donationDuration, + clickedPaymentMethod: paymentMethod, + email, + context: 'donate page' + } }); - console.error(error); } renderAmountButtons(duration) { @@ -351,7 +340,8 @@ class DonateForm extends Component { id='confirm-donation-btn' onClick={e => this.handleStripeCheckoutRedirect(e, 'credit card')} > - {t('donate.credit-card')} + {} + {t('donate.credit-card')} - {processing && + {(processing || redirecting) && this.renderCompletion({ processing, + redirecting, success, error, reset: this.resetDonation })} -
+
{isMinimalForm ? this.renderModalForm(processing) : this.renderPageForm(processing)} diff --git a/client/src/redux/donation-saga.js b/client/src/redux/donation-saga.js index e09a7634e0..c918d397fe 100644 --- a/client/src/redux/donation-saga.js +++ b/client/src/redux/donation-saga.js @@ -21,7 +21,11 @@ import { types as appTypes } from './'; -import { addDonation, postChargeStripe } from '../utils/ajax'; +import { + addDonation, + postChargeStripe, + postCreateStripeSession +} from '../utils/ajax'; const defaultDonationError = `Something is not right. Please contact donors@freecodecamp.org`; @@ -55,6 +59,24 @@ function* addDonationSaga({ payload }) { } } +function* createStripeSessionSaga({ payload: { stripe, data } }) { + try { + const session = yield call(postCreateStripeSession, { + ...data, + location: window.location.href + }); + stripe.redirectToCheckout({ + sessionId: session.data.id + }); + } catch (error) { + const err = + error.response && error.response.data + ? error.response.data.message + : defaultDonationError; + yield put(addDonationError(err)); + } +} + function* postChargeStripeSaga({ payload }) { try { yield call(postChargeStripe, payload); @@ -72,6 +94,7 @@ export function createDonationSaga(types) { return [ takeEvery(types.tryToShowDonationModal, showDonateModalSaga), takeEvery(types.addDonation, addDonationSaga), - takeLeading(types.postChargeStripe, postChargeStripeSaga) + takeLeading(types.postChargeStripe, postChargeStripeSaga), + takeLeading(types.createStripeSession, createStripeSessionSaga) ]; } diff --git a/client/src/redux/index.js b/client/src/redux/index.js index f77a2f2426..76d88ac205 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -31,6 +31,7 @@ export const defaultFetchState = { }; export const defaultDonationFormState = { + redirecting: false, processing: false, success: false, error: '' @@ -81,6 +82,7 @@ export const types = createTypes( 'updateDonationFormState', ...createAsyncTypes('fetchUser'), ...createAsyncTypes('addDonation'), + ...createAsyncTypes('createStripeSession'), ...createAsyncTypes('postChargeStripe'), ...createAsyncTypes('fetchProfileForUser'), ...createAsyncTypes('acceptTerms'), @@ -150,6 +152,8 @@ export const addDonation = createAction(types.addDonation); export const addDonationComplete = createAction(types.addDonationComplete); export const addDonationError = createAction(types.addDonationError); +export const createStripeSession = createAction(types.createStripeSession); + export const postChargeStripe = createAction(types.postChargeStripe); export const postChargeStripeComplete = createAction( types.postChargeStripeComplete @@ -400,6 +404,10 @@ export const reducer = handleActions( ...state, donationFormState: { ...state.donationFormState, ...payload } }), + [types.createStripeSession]: state => ({ + ...state, + donationFormState: { ...defaultDonationFormState, redirecting: true } + }), [types.addDonation]: state => ({ ...state, donationFormState: { ...defaultDonationFormState, processing: true } diff --git a/client/src/utils/ajax.js b/client/src/utils/ajax.js index d411dac75a..fde6346251 100644 --- a/client/src/utils/ajax.js +++ b/client/src/utils/ajax.js @@ -68,6 +68,10 @@ export function addDonation(body) { return post('/donate/add-donation', body); } +export function postCreateStripeSession(body) { + return post('/donate/create-stripe-session', body); +} + export function putUpdateLegacyCert(body) { return post('/update-my-projects', body); }