feat(api): stripe checkout integration (#41666)

* feat: add api stripe checkout integration

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2021-04-02 09:33:34 +03:00
committed by GitHub
parent eafb8ae34b
commit d5d786049e
8 changed files with 134 additions and 42 deletions

View File

@ -11,9 +11,13 @@ import {
import { import {
durationKeysConfig, durationKeysConfig,
donationOneTimeConfig, donationOneTimeConfig,
donationSubscriptionConfig donationSubscriptionConfig,
durationsConfig,
onetimeSKUConfig,
donationUrls
} from '../../../../config/donation-settings'; } from '../../../../config/donation-settings';
import keys from '../../../../config/secrets'; import keys from '../../../../config/secrets';
import { deploymentEnv } from '../../../../config/env';
const log = debug('fcc:boot:donate'); 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) { function updatePaypal(req, res) {
const { headers, body } = req; const { headers, body } = req;
return Promise.resolve(req) return Promise.resolve(req)
@ -284,6 +335,7 @@ export default function donateBoot(app, done) {
done(); done();
} else { } else {
api.post('/charge-stripe', createStripeDonation); api.post('/charge-stripe', createStripeDonation);
api.post('/create-stripe-session', createStripeSession);
api.post('/add-donation', addDonation); api.post('/add-donation', addDonation);
hooks.post('/update-paypal', updatePaypal); hooks.post('/update-paypal', updatePaypal);
donateRouter.use('/donate', api); donateRouter.use('/donate', api);

View File

@ -24,6 +24,7 @@ const statusRE = /^\/status\/ping$/;
const unsubscribedRE = /^\/unsubscribed\//; const unsubscribedRE = /^\/unsubscribed\//;
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//; const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
const updateHooksRE = /^\/hooks\/update-paypal$|^\/hooks\/update-stripe$/; const updateHooksRE = /^\/hooks\/update-paypal$|^\/hooks\/update-stripe$/;
const createStripeSession = /^\/donate\/create-stripe-session/;
// note: this would be replaced by webhooks later // note: this would be replaced by webhooks later
const donateRE = /^\/donate\/charge-stripe$/; const donateRE = /^\/donate\/charge-stripe$/;
@ -41,7 +42,8 @@ const _pathsAllowedREs = [
unsubscribedRE, unsubscribedRE,
unsubscribeRE, unsubscribeRE,
updateHooksRE, updateHooksRE,
donateRE donateRE,
createStripeSession
]; ];
export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) { export function isAllowedPath(path, pathsAllowedREs = _pathsAllowedREs) {

View File

@ -289,6 +289,7 @@
"donate": { "donate": {
"title": "Support our nonprofit", "title": "Support our nonprofit",
"processing": "We are processing your donation.", "processing": "We are processing your donation.",
"redirecting": "Redirecting...",
"thanks": "Thanks for donating", "thanks": "Thanks for donating",
"thank-you": "Thank you for being a supporter.", "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.", "thank-you-2": "Thank you for being a supporter of freeCodeCamp. You currently have a recurring donation.",

View File

@ -9,15 +9,25 @@ import './Donation.css';
const propTypes = { const propTypes = {
error: PropTypes.string, error: PropTypes.string,
processing: PropTypes.bool, processing: PropTypes.bool,
redirecting: PropTypes.bool,
reset: PropTypes.func.isRequired, reset: PropTypes.func.isRequired,
success: PropTypes.bool success: PropTypes.bool
}; };
function DonateCompletion({ processing, reset, success, error = null }) { function DonateCompletion({
processing,
reset,
success,
redirecting,
error = null
}) {
/* eslint-disable no-nested-ternary */ /* eslint-disable no-nested-ternary */
const { t } = useTranslation(); const { t } = useTranslation();
const style = processing ? 'info' : success ? 'success' : 'danger'; const style =
const heading = processing processing || redirecting ? 'info' : success ? 'success' : 'danger';
const heading = redirecting
? `${t('donate.redirecting')}`
: processing
? `${t('donate.processing')}` ? `${t('donate.processing')}`
: success : success
? `${t('donate.thank-you')}` ? `${t('donate.thank-you')}`
@ -28,7 +38,7 @@ function DonateCompletion({ processing, reset, success, error = null }) {
<b>{heading}</b> <b>{heading}</b>
</h4> </h4>
<div className='donation-completion-body'> <div className='donation-completion-body'>
{processing && ( {(processing || redirecting) && (
<Spinner <Spinner
className='user-state-spinner' className='user-state-spinner'
color='#0a0a23' color='#0a0a23'

View File

@ -20,7 +20,6 @@ import {
durationsConfig, durationsConfig,
defaultAmount, defaultAmount,
defaultDonation, defaultDonation,
onetimeSKUConfig,
donationUrls, donationUrls,
modalDefaultDonation modalDefaultDonation
} from '../../../../config/donation-settings'; } from '../../../../config/donation-settings';
@ -36,6 +35,7 @@ import {
donationFormStateSelector, donationFormStateSelector,
hardGoTo as navigate, hardGoTo as navigate,
addDonation, addDonation,
createStripeSession,
postChargeStripe, postChargeStripe,
updateDonationFormState, updateDonationFormState,
defaultDonationFormState, defaultDonationFormState,
@ -44,13 +44,14 @@ import {
import './Donation.css'; import './Donation.css';
const { stripePublicKey, deploymentEnv } = envData; const { stripePublicKey } = envData;
const numToCommas = num => const numToCommas = num =>
num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,'); num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
const propTypes = { const propTypes = {
addDonation: PropTypes.func, addDonation: PropTypes.func,
createStripeSession: PropTypes.func,
defaultTheme: PropTypes.string, defaultTheme: PropTypes.string,
donationFormState: PropTypes.object, donationFormState: PropTypes.object,
email: PropTypes.string, email: PropTypes.string,
@ -84,7 +85,8 @@ const mapDispatchToProps = {
addDonation, addDonation,
navigate, navigate,
postChargeStripe, postChargeStripe,
updateDonationFormState updateDonationFormState,
createStripeSession
}; };
class DonateForm extends Component { class DonateForm extends Component {
@ -215,39 +217,26 @@ class DonateForm extends Component {
} }
async handleStripeCheckoutRedirect(e, paymentMethod) { async handleStripeCheckoutRedirect(e, paymentMethod) {
const { stripe } = this.state; e.preventDefault();
const { donationAmount, donationDuration } = this.state; const { stripe, donationAmount, donationDuration } = this.state;
const { handleProcessing, email } = this.props;
this.props.handleProcessing( handleProcessing(
donationDuration, donationDuration,
donationAmount, donationAmount,
`stripe (${paymentMethod}) button click` `stripe (${paymentMethod}) button click`
); );
const isOneTime = donationDuration === 'onetime'; this.props.createStripeSession({
const getSKUId = () => { stripe,
const { id } = onetimeSKUConfig[deploymentEnv || 'staging'].find( data: {
skuConfig => skuConfig.amount === `${donationAmount}` donationAmount,
); donationDuration,
return id; clickedPaymentMethod: paymentMethod,
}; email,
context: 'donate page'
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
}); });
console.error(error);
} }
renderAmountButtons(duration) { renderAmountButtons(duration) {
@ -351,7 +340,8 @@ class DonateForm extends Component {
id='confirm-donation-btn' id='confirm-donation-btn'
onClick={e => this.handleStripeCheckoutRedirect(e, 'credit card')} onClick={e => this.handleStripeCheckoutRedirect(e, 'credit card')}
> >
<b>{t('donate.credit-card')}</b> {}
<b>{t('donate.credit-card')} </b>
</Button> </Button>
<PaypalButton <PaypalButton
addDonation={addDonation} addDonation={addDonation}
@ -430,29 +420,31 @@ class DonateForm extends Component {
render() { render() {
const { const {
donationFormState: { processing, success, error }, donationFormState: { processing, success, error, redirecting },
isMinimalForm isMinimalForm
} = this.props; } = this.props;
if (success || error) { if (success || error) {
return this.renderCompletion({ return this.renderCompletion({
processing, processing,
redirecting,
success, success,
error, error,
reset: this.resetDonation reset: this.resetDonation
}); });
} }
// keep payment provider elements on DOM during processing to avoid errors. // keep payment provider elements on DOM during processing and redirect to avoid errors.
return ( return (
<> <>
{processing && {(processing || redirecting) &&
this.renderCompletion({ this.renderCompletion({
processing, processing,
redirecting,
success, success,
error, error,
reset: this.resetDonation reset: this.resetDonation
})} })}
<div className={processing ? 'hide' : ''}> <div className={processing || redirecting ? 'hide' : ''}>
{isMinimalForm {isMinimalForm
? this.renderModalForm(processing) ? this.renderModalForm(processing)
: this.renderPageForm(processing)} : this.renderPageForm(processing)}

View File

@ -21,7 +21,11 @@ import {
types as appTypes types as appTypes
} from './'; } 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`; 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 }) { function* postChargeStripeSaga({ payload }) {
try { try {
yield call(postChargeStripe, payload); yield call(postChargeStripe, payload);
@ -72,6 +94,7 @@ export function createDonationSaga(types) {
return [ return [
takeEvery(types.tryToShowDonationModal, showDonateModalSaga), takeEvery(types.tryToShowDonationModal, showDonateModalSaga),
takeEvery(types.addDonation, addDonationSaga), takeEvery(types.addDonation, addDonationSaga),
takeLeading(types.postChargeStripe, postChargeStripeSaga) takeLeading(types.postChargeStripe, postChargeStripeSaga),
takeLeading(types.createStripeSession, createStripeSessionSaga)
]; ];
} }

View File

@ -31,6 +31,7 @@ export const defaultFetchState = {
}; };
export const defaultDonationFormState = { export const defaultDonationFormState = {
redirecting: false,
processing: false, processing: false,
success: false, success: false,
error: '' error: ''
@ -81,6 +82,7 @@ export const types = createTypes(
'updateDonationFormState', 'updateDonationFormState',
...createAsyncTypes('fetchUser'), ...createAsyncTypes('fetchUser'),
...createAsyncTypes('addDonation'), ...createAsyncTypes('addDonation'),
...createAsyncTypes('createStripeSession'),
...createAsyncTypes('postChargeStripe'), ...createAsyncTypes('postChargeStripe'),
...createAsyncTypes('fetchProfileForUser'), ...createAsyncTypes('fetchProfileForUser'),
...createAsyncTypes('acceptTerms'), ...createAsyncTypes('acceptTerms'),
@ -150,6 +152,8 @@ export const addDonation = createAction(types.addDonation);
export const addDonationComplete = createAction(types.addDonationComplete); export const addDonationComplete = createAction(types.addDonationComplete);
export const addDonationError = createAction(types.addDonationError); export const addDonationError = createAction(types.addDonationError);
export const createStripeSession = createAction(types.createStripeSession);
export const postChargeStripe = createAction(types.postChargeStripe); export const postChargeStripe = createAction(types.postChargeStripe);
export const postChargeStripeComplete = createAction( export const postChargeStripeComplete = createAction(
types.postChargeStripeComplete types.postChargeStripeComplete
@ -400,6 +404,10 @@ export const reducer = handleActions(
...state, ...state,
donationFormState: { ...state.donationFormState, ...payload } donationFormState: { ...state.donationFormState, ...payload }
}), }),
[types.createStripeSession]: state => ({
...state,
donationFormState: { ...defaultDonationFormState, redirecting: true }
}),
[types.addDonation]: state => ({ [types.addDonation]: state => ({
...state, ...state,
donationFormState: { ...defaultDonationFormState, processing: true } donationFormState: { ...defaultDonationFormState, processing: true }

View File

@ -68,6 +68,10 @@ export function addDonation(body) {
return post('/donate/add-donation', body); return post('/donate/add-donation', body);
} }
export function postCreateStripeSession(body) {
return post('/donate/create-stripe-session', body);
}
export function putUpdateLegacyCert(body) { export function putUpdateLegacyCert(body) {
return post('/update-my-projects', body); return post('/update-my-projects', body);
} }