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:
@ -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);
|
||||||
|
@ -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) {
|
||||||
|
@ -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.",
|
||||||
|
@ -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'
|
||||||
|
@ -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)}
|
||||||
|
@ -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)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user