feat(donate): integrate servicebot

This commit is contained in:
Mrugesh Mohapatra
2019-11-13 19:40:49 +05:30
parent 2cb8c16b28
commit aeec1bb9e6
12 changed files with 411 additions and 40 deletions

View File

@ -1,5 +1,6 @@
import Stripe from 'stripe'; import Stripe from 'stripe';
import debug from 'debug'; import debug from 'debug';
import crypto from 'crypto';
import { isEmail, isNumeric } from 'validator'; import { isEmail, isNumeric } from 'validator';
import keys from '../../../config/secrets'; import keys from '../../../config/secrets';
@ -108,18 +109,12 @@ export default function donateBoot(app, done) {
function createStripeDonation(req, res) { function createStripeDonation(req, res) {
const { user, body } = req; const { user, body } = req;
if (!user) { if (!user || !body) {
return res return res
.status(500) .status(500)
.send({ error: 'User must be signed in for this request.' }); .send({ error: 'User must be signed in for this request.' });
} }
if (!body || !body.amount || !body.duration) {
return res.status(500).send({
error: 'The donation form had invalid values for this submission.'
});
}
const { const {
amount, amount,
duration, duration,
@ -218,12 +213,54 @@ export default function donateBoot(app, done) {
}); });
} }
function createHmacHash(req, res) {
const { user, body } = req;
if (!user || !body) {
return res
.status(500)
.send({ error: 'User must be signed in for this request.' });
}
const { email } = body;
if (!isEmail('' + email)) {
return res
.status(500)
.send({ error: 'The email is invalid for this request.' });
}
if (!user.donationEmails.includes(email)) {
return res.status(500).send({
error: `User does not have the email: ${email} associated with their donations.`
});
}
log(`creating HMAC hash for ${email}`);
return Promise.resolve(email)
.then(email =>
crypto
.createHmac('sha256', keys.servicebot.hmacKey)
.update(email)
.digest('hex')
)
.then(hash => res.status(200).json({ hash }))
.catch(() =>
res
.status(500)
.send({ error: 'Donation failed due to a server error.' })
);
}
const pubKey = keys.stripe.public; const pubKey = keys.stripe.public;
const secKey = keys.stripe.secret; const secKey = keys.stripe.secret;
const secretInvalid = !secKey || secKey === 'sk_from_stipe_dashboard'; const hmacKey = keys.servicebot.hmacKey;
const publicInvalid = !pubKey || pubKey === 'pk_from_stipe_dashboard'; const secretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
const publicInvalid = !pubKey || pubKey === 'pk_from_stripe_dashboard';
const hmacKeyInvalid =
!hmacKey || hmacKey === 'secret_key_from_servicebot_dashboard';
if (secretInvalid || publicInvalid) { if (secretInvalid || publicInvalid || hmacKeyInvalid) {
if (process.env.FREECODECAMP_NODE_ENV === 'production') { if (process.env.FREECODECAMP_NODE_ENV === 'production') {
throw new Error('Stripe API keys are required to boot the server!'); throw new Error('Stripe API keys are required to boot the server!');
} }
@ -231,6 +268,7 @@ export default function donateBoot(app, done) {
done(); done();
} else { } else {
api.post('/charge-stripe', createStripeDonation); api.post('/charge-stripe', createStripeDonation);
api.post('/create-hmac-hash', createHmacHash);
donateRouter.use('/donate', api); donateRouter.use('/donate', api);
app.use(donateRouter); app.use(donateRouter);
app.use('/internal', donateRouter); app.use('/internal', donateRouter);

View File

@ -51,7 +51,8 @@ export const userPropsForSession = [
'completedProjectCount', 'completedProjectCount',
'completedCertCount', 'completedCertCount',
'completedLegacyCertCount', 'completedLegacyCertCount',
'acceptedPrivacyTerms' 'acceptedPrivacyTerms',
'donationEmails'
]; ];
export function normaliseUserFields(user) { export function normaliseUserFields(user) {

View File

@ -42,7 +42,7 @@ function DonateCompletion({ processing, reset, success, error = null }) {
</p> </p>
<p> <p>
You can update your supporter status at any time from the 'manage You can update your supporter status at any time from the 'manage
your existing donation' section on this page. your existing donation' section below on this page.
</p> </p>
</div> </div>
)} )}

View File

@ -30,6 +30,7 @@ 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 = {
enableDonationSettingsPage: PropTypes.func.isRequired,
isDonating: PropTypes.bool, isDonating: PropTypes.bool,
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
navigate: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired,
@ -204,7 +205,7 @@ class DonateForm extends Component {
} }
renderDonationOptions() { renderDonationOptions() {
const { stripe } = this.props; const { stripe, enableDonationSettingsPage } = this.props;
const { const {
donationAmount, donationAmount,
donationDuration, donationDuration,
@ -242,6 +243,7 @@ class DonateForm extends Component {
<DonateFormChildViewForHOC <DonateFormChildViewForHOC
donationAmount={donationAmount} donationAmount={donationAmount}
donationDuration={donationDuration} donationDuration={donationDuration}
enableDonationSettingsPage={enableDonationSettingsPage}
getDonationButtonLabel={this.getDonationButtonLabel} getDonationButtonLabel={this.getDonationButtonLabel}
hideAmountOptionsCB={this.hideAmountOptionsCB} hideAmountOptionsCB={this.hideAmountOptionsCB}
/> />
@ -288,18 +290,6 @@ class DonateForm extends Component {
this.renderDonationOptions() this.renderDonationOptions()
)} )}
</Col> </Col>
<Col sm={10} smOffset={1} xs={12}>
<Spacer size={2} />
<h3 className='text-center'>Manage your existing donation</h3>
<Button block={true} bsStyle='primary' disabled={true}>
Update your existing donation
</Button>
<Spacer />
<Button block={true} bsStyle='primary' disabled={true}>
Download donation receipts
</Button>
<Spacer />
</Col>
</Row> </Row>
); );
} }

View File

@ -21,6 +21,7 @@ const propTypes = {
donationAmount: PropTypes.number.isRequired, donationAmount: PropTypes.number.isRequired,
donationDuration: PropTypes.string.isRequired, donationDuration: PropTypes.string.isRequired,
email: PropTypes.string, email: PropTypes.string,
enableDonationSettingsPage: PropTypes.func.isRequired,
getDonationButtonLabel: PropTypes.func.isRequired, getDonationButtonLabel: PropTypes.func.isRequired,
hideAmountOptionsCB: PropTypes.func.isRequired, hideAmountOptionsCB: PropTypes.func.isRequired,
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
@ -119,6 +120,7 @@ class DonateFormChildViewForHOC extends Component {
} }
postDonation(token) { postDonation(token) {
const { enableDonationSettingsPage } = this.props;
const { donationAmount: amount, donationDuration: duration } = this.state; const { donationAmount: amount, donationDuration: duration } = this.state;
this.setState(state => ({ this.setState(state => ({
...state, ...state,
@ -144,6 +146,7 @@ class DonateFormChildViewForHOC extends Component {
error: data.error ? data.error : null error: data.error ? data.error : null
} }
})); }));
enableDonationSettingsPage();
}) })
.catch(error => { .catch(error => {
const data = const data =

View File

@ -0,0 +1,71 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { servicebotId } from '../../../../config/env.json';
import { servicebotScriptLoader } from '../../../utils/scriptLoaders';
import '../Donation.css';
const propTypes = {
email: PropTypes.string.isRequired,
hash: PropTypes.string.isRequired
};
export class DonationServicebotEmbed extends Component {
constructor(...props) {
super(...props);
this.state = {
email: this.props.email,
hash: this.props.hash
};
this.setServiceBotConfig = this.setServiceBotConfig.bind(this);
}
setServiceBotConfig() {
const { email, hash } = this.state;
/* eslint-disable camelcase */
window.servicebotSettings = {
type: 'portal',
servicebot_id: servicebotId,
service: 'freeCodeCamp.org',
email,
hash,
options: {
cancel_now: true,
disableCoupon: true,
forceCard: true,
disableTiers: [
'Monthly $10 Donation - Unavailable',
'Monthly $3 Donation - Unavailable'
],
card: {
hideName: true,
hideAddress: true,
hideCountryPostal: true
},
messageOnCancel: `Thanks again for supporting our tiny nonprofit. We are helping millions of people around the world learn to code for free. Please confirm: are you certain you want to stop your donation?`
}
};
/* eslint-enable camelcase */
}
componentDidMount() {
servicebotScriptLoader();
}
render() {
this.setServiceBotConfig();
return (
<div className='fcc-servicebot-embed-portal'>
<div id='servicebot-subscription-portal'></div>
</div>
);
}
}
DonationServicebotEmbed.displayName = 'DonationServicebotEmbed';
DonationServicebotEmbed.propTypes = propTypes;
export default DonationServicebotEmbed;

View File

@ -3,32 +3,40 @@ import Helmet from 'react-helmet';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Grid, Row, Col } from '@freecodecamp/react-bootstrap'; import { Grid, Row, Col, Button } from '@freecodecamp/react-bootstrap';
import { stripePublicKey } from '../../config/env.json'; import { stripePublicKey } from '../../config/env.json';
import { Spacer, Loader } from '../components/helpers'; import { Spacer, Loader } from '../components/helpers';
import DonateForm from '../components/Donation/components/DonateForm'; import DonateForm from '../components/Donation/components/DonateForm';
import DonateText from '../components/Donation/components/DonateText'; import DonateText from '../components/Donation/components/DonateText';
import { signInLoadingSelector } from '../redux'; import { signInLoadingSelector, userSelector } from '../redux';
import { stripeScriptLoader } from '../utils/scriptLoaders'; import { stripeScriptLoader } from '../utils/scriptLoaders';
const propTypes = {
isDonating: PropTypes.bool,
showLoading: PropTypes.bool.isRequired
};
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
userSelector,
signInLoadingSelector, signInLoadingSelector,
showLoading => ({ ({ isDonating }, showLoading) => ({
isDonating,
showLoading showLoading
}) })
); );
const propTypes = {
showLoading: PropTypes.bool.isRequired
};
export class DonatePage extends Component { export class DonatePage extends Component {
constructor(...props) { constructor(...props) {
super(...props); super(...props);
this.state = { this.state = {
stripe: null stripe: null,
enableSettings: false
}; };
this.enableDonationSettingsPage = this.enableDonationSettingsPage.bind(
this
);
this.handleStripeLoad = this.handleStripeLoad.bind(this); this.handleStripeLoad = this.handleStripeLoad.bind(this);
} }
@ -60,9 +68,14 @@ export class DonatePage extends Component {
})); }));
} }
enableDonationSettingsPage(enableSettings = true) {
this.setState({ enableSettings });
}
render() { render() {
const { stripe } = this.state; const { stripe } = this.state;
const { showLoading } = this.props; const { showLoading, isDonating } = this.props;
const { enableSettings } = this.state;
if (showLoading) { if (showLoading) {
return <Loader fullScreen={true} />; return <Loader fullScreen={true} />;
@ -81,7 +94,32 @@ export class DonatePage extends Component {
</Row> </Row>
<Row> <Row>
<Col md={6}> <Col md={6}>
<DonateForm stripe={stripe} /> <DonateForm
enableDonationSettingsPage={this.enableDonationSettingsPage}
stripe={stripe}
/>
<Row>
<Col sm={10} smOffset={1} xs={12}>
<Spacer size={2} />
<h3 className='text-center'>Manage your existing donation</h3>
{[
`Update your existing donation`,
`Download donation receipts`
].map(donationSettingOps => (
<div key={donationSettingOps}>
<Button
block={true}
bsStyle='primary'
disabled={!isDonating && !enableSettings}
href='/donation/settings'
>
{donationSettingOps}
</Button>
<Spacer />
</div>
))}
</Col>
</Row>
</Col> </Col>
<Col md={6}> <Col md={6}>
<DonateText /> <DonateText />

View File

@ -0,0 +1,209 @@
import React, { Component, Fragment } from 'react';
import Helmet from 'react-helmet';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Grid, Row, Col, Button } from '@freecodecamp/react-bootstrap';
import { uniq } from 'lodash';
import { apiLocation } from '../../../config/env.json';
import { postCreateHmacHash } from '../../utils/ajax';
import {
signInLoadingSelector,
userSelector,
hardGoTo as navigate,
isSignedInSelector
} from '../../redux';
// eslint-disable-next-line max-len
import DonateServicebotEmbed from '../../components/Donation/components/DonateServicebotEmbed';
import { Loader, Spacer, Link } from '../../components/helpers';
const propTypes = {
donationEmails: PropTypes.array,
email: PropTypes.string,
isDonating: PropTypes.bool,
isSignedIn: PropTypes.bool,
navigate: PropTypes.func.isRequired,
showLoading: PropTypes.bool.isRequired
};
const mapStateToProps = createSelector(
isSignedInSelector,
userSelector,
signInLoadingSelector,
(isSignedIn, { email, isDonating, donationEmails }, showLoading) => ({
isSignedIn,
email,
isDonating,
donationEmails,
showLoading
})
);
const mapDispatchToProps = {
navigate
};
export class DonationSettingsPage extends Component {
constructor(...props) {
super(...props);
this.state = {
hash: null,
currentSettingsEmail: null
};
this.getEmailHmacHash = this.getEmailHmacHash.bind(this);
this.handleSelectDonationEmail = this.handleSelectDonationEmail.bind(this);
}
getEmailHmacHash(currentSettingsEmail) {
return postCreateHmacHash({
email: currentSettingsEmail
})
.then(response => {
const data = response && response.data;
this.setState({ hash: '' + data.hash, currentSettingsEmail });
})
.catch(error => {
const data =
error.response && error.response.data
? error.response.data
: {
error:
'Something is not right. Please contact team@freecodecamp.org'
};
console.error(data.error);
});
}
handleSelectDonationEmail(e) {
e.preventDefault();
this.setState({ hash: null, currentSettingsEmail: null });
this.getEmailHmacHash(e.currentTarget.value);
}
renderServicebotEmbed() {
const { currentSettingsEmail, hash } = this.state;
if (!hash && !currentSettingsEmail) {
return null;
}
return (
<div>
<Spacer />
<DonateServicebotEmbed email={currentSettingsEmail} hash={hash} />
</div>
);
}
renderDonationEmailsList() {
const { donationEmails } = this.props;
return (
<div>
{uniq(donationEmails).map((email, index) => (
<div key={email + '-' + index}>
<Button
bsStyle='primary'
className='btn btn-block'
onClick={this.handleSelectDonationEmail}
value={email}
>
{`Show donations for your ${email} email address`}
</Button>
<Spacer />
</div>
))}
</div>
);
}
render() {
const { showLoading, isSignedIn, isDonating, navigate } = this.props;
if (showLoading) {
return <Loader fullScreen={true} />;
}
if (!showLoading && !isSignedIn) {
navigate(`${apiLocation}/signin?returnTo=donation/settings`);
return <Loader fullScreen={true} />;
}
if (!showLoading && !isDonating) {
navigate(`/donate`);
return <Loader fullScreen={true} />;
}
return (
<Fragment>
<Helmet title='Manage your donation | freeCodeCamp.org' />
<Grid>
<Row>
<Col sm={6} smOffset={3} xs={12}>
<Spacer size={2} />
<Button block={true} bsStyle='primary' href='/donate'>
Go to donate page
</Button>
</Col>
</Row>
<Row>
<Col sm={8} smOffset={2} xs={12}>
<Spacer />
<h1 className='text-center'>Manage your donations</h1>
<Spacer />
<h3 className='text-center'>
Donations made using a credit or debit card
</h3>
</Col>
</Row>
<Row>
<Col sm={6} smOffset={3} xs={12}>
{this.renderDonationEmailsList()}
</Col>
</Row>
<Row>
<Col sm={8} smOffset={2} xs={12}>
{this.renderServicebotEmbed()}
</Col>
</Row>
<Row>
<Col sm={8} smOffset={2} xs={12}>
<hr />
<h3 className='text-center'>Donations made using PayPal</h3>
<p className='text-center'>
You can update your PayPal donation{' '}
<Link
external={true}
to='https://www.paypal.com/cgi-bin/webscr?cmd=_manage-paylist'
>
directly on PayPal
</Link>
.
</p>
</Col>
</Row>
<Row>
<Col sm={8} smOffset={2} xs={12}>
<hr />
<h3 className='text-center'>Still need help?</h3>
<p>
If you can't see your donation here, forward a donation receipt
you have recieved in your email to team@freeCodeCamp.org and
tell us how we can help you with it.
</p>
<Spacer />
</Col>
</Row>
</Grid>
</Fragment>
);
}
}
DonationSettingsPage.displayName = 'DonationSettingsPage';
DonationSettingsPage.propTypes = propTypes;
export default connect(
mapStateToProps,
mapDispatchToProps
)(DonationSettingsPage);

View File

@ -54,6 +54,10 @@ export function postChargeStripe(body) {
return post(`/donate/charge-stripe`, body); return post(`/donate/charge-stripe`, body);
} }
export function postCreateHmacHash(body) {
return post(`/donate/create-hmac-hash`, body);
}
export function putUpdateLegacyCert(body) { export function putUpdateLegacyCert(body) {
return post('/update-my-projects', body); return post('/update-my-projects', body);
} }

View File

@ -19,6 +19,14 @@ export const stripeScriptLoader = onload =>
onload onload
); );
export const servicebotScriptLoader = () =>
scriptLoader(
'servicebot-billing-settings-embed.js',
'servicebot-billing-settings-embed.js',
true,
'https://js.servicebot.io/embeds/servicebot-billing-settings-embed.js'
);
export const mathJaxScriptLoader = () => export const mathJaxScriptLoader = () =>
scriptLoader( scriptLoader(
'mathjax', 'mathjax',

View File

@ -13,7 +13,8 @@ const {
FORUM_PROXY: forumProxy, FORUM_PROXY: forumProxy,
NEWS_PROXY: newsProxy, NEWS_PROXY: newsProxy,
LOCALE: locale, LOCALE: locale,
STRIPE_PUBLIC: stripePublicKey, STRIPE_PUBLIC_KEY: stripePublicKey,
SERVICEBOT_ID: servicebotId,
ALGOLIA_APP_ID: algoliaAppId, ALGOLIA_APP_ID: algoliaAppId,
ALGOLIA_API_KEY: algoliaAPIKey ALGOLIA_API_KEY: algoliaAPIKey
} = process.env; } = process.env;
@ -30,6 +31,7 @@ const locations = {
module.exports = Object.assign(locations, { module.exports = Object.assign(locations, {
locale, locale,
stripePublicKey, stripePublicKey,
servicebotId,
algoliaAppId: algoliaAppId:
!algoliaAppId || algoliaAppId === 'Algolia app id from dashboard' !algoliaAppId || algoliaAppId === 'Algolia app id from dashboard'
? null ? null

View File

@ -30,8 +30,10 @@ const {
ROLLBAR_APP_ID, ROLLBAR_APP_ID,
ROLLBAR_CLIENT_ID, ROLLBAR_CLIENT_ID,
STRIPE_PUBLIC, STRIPE_PUBLIC_KEY,
STRIPE_SECRET STRIPE_SECRET_KEY,
SERVICEBOT_ID,
SERVICEBOT_HMAC_SECRET_KEY
} = process.env; } = process.env;
module.exports = { module.exports = {
@ -92,7 +94,12 @@ module.exports = {
}, },
stripe: { stripe: {
public: STRIPE_PUBLIC, public: STRIPE_PUBLIC_KEY,
secret: STRIPE_SECRET secret: STRIPE_SECRET_KEY
},
servicebot: {
servicebotId: SERVICEBOT_ID,
hmacKey: SERVICEBOT_HMAC_SECRET_KEY
} }
}; };