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 debug from 'debug';
import crypto from 'crypto';
import { isEmail, isNumeric } from 'validator';
import keys from '../../../config/secrets';
@ -108,18 +109,12 @@ export default function donateBoot(app, done) {
function createStripeDonation(req, res) {
const { user, body } = req;
if (!user) {
if (!user || !body) {
return res
.status(500)
.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 {
amount,
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 secKey = keys.stripe.secret;
const secretInvalid = !secKey || secKey === 'sk_from_stipe_dashboard';
const publicInvalid = !pubKey || pubKey === 'pk_from_stipe_dashboard';
const hmacKey = keys.servicebot.hmacKey;
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') {
throw new Error('Stripe API keys are required to boot the server!');
}
@ -231,6 +268,7 @@ export default function donateBoot(app, done) {
done();
} else {
api.post('/charge-stripe', createStripeDonation);
api.post('/create-hmac-hash', createHmacHash);
donateRouter.use('/donate', api);
app.use(donateRouter);
app.use('/internal', donateRouter);

View File

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

View File

@ -42,7 +42,7 @@ function DonateCompletion({ processing, reset, success, error = null }) {
</p>
<p>
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>
</div>
)}

View File

@ -30,6 +30,7 @@ const numToCommas = num =>
num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,');
const propTypes = {
enableDonationSettingsPage: PropTypes.func.isRequired,
isDonating: PropTypes.bool,
isSignedIn: PropTypes.bool,
navigate: PropTypes.func.isRequired,
@ -204,7 +205,7 @@ class DonateForm extends Component {
}
renderDonationOptions() {
const { stripe } = this.props;
const { stripe, enableDonationSettingsPage } = this.props;
const {
donationAmount,
donationDuration,
@ -242,6 +243,7 @@ class DonateForm extends Component {
<DonateFormChildViewForHOC
donationAmount={donationAmount}
donationDuration={donationDuration}
enableDonationSettingsPage={enableDonationSettingsPage}
getDonationButtonLabel={this.getDonationButtonLabel}
hideAmountOptionsCB={this.hideAmountOptionsCB}
/>
@ -288,18 +290,6 @@ class DonateForm extends Component {
this.renderDonationOptions()
)}
</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>
);
}

View File

@ -21,6 +21,7 @@ const propTypes = {
donationAmount: PropTypes.number.isRequired,
donationDuration: PropTypes.string.isRequired,
email: PropTypes.string,
enableDonationSettingsPage: PropTypes.func.isRequired,
getDonationButtonLabel: PropTypes.func.isRequired,
hideAmountOptionsCB: PropTypes.func.isRequired,
isSignedIn: PropTypes.bool,
@ -119,6 +120,7 @@ class DonateFormChildViewForHOC extends Component {
}
postDonation(token) {
const { enableDonationSettingsPage } = this.props;
const { donationAmount: amount, donationDuration: duration } = this.state;
this.setState(state => ({
...state,
@ -144,6 +146,7 @@ class DonateFormChildViewForHOC extends Component {
error: data.error ? data.error : null
}
}));
enableDonationSettingsPage();
})
.catch(error => {
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 { connect } from 'react-redux';
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 { Spacer, Loader } from '../components/helpers';
import DonateForm from '../components/Donation/components/DonateForm';
import DonateText from '../components/Donation/components/DonateText';
import { signInLoadingSelector } from '../redux';
import { signInLoadingSelector, userSelector } from '../redux';
import { stripeScriptLoader } from '../utils/scriptLoaders';
const propTypes = {
isDonating: PropTypes.bool,
showLoading: PropTypes.bool.isRequired
};
const mapStateToProps = createSelector(
userSelector,
signInLoadingSelector,
showLoading => ({
({ isDonating }, showLoading) => ({
isDonating,
showLoading
})
);
const propTypes = {
showLoading: PropTypes.bool.isRequired
};
export class DonatePage extends Component {
constructor(...props) {
super(...props);
this.state = {
stripe: null
stripe: null,
enableSettings: false
};
this.enableDonationSettingsPage = this.enableDonationSettingsPage.bind(
this
);
this.handleStripeLoad = this.handleStripeLoad.bind(this);
}
@ -60,9 +68,14 @@ export class DonatePage extends Component {
}));
}
enableDonationSettingsPage(enableSettings = true) {
this.setState({ enableSettings });
}
render() {
const { stripe } = this.state;
const { showLoading } = this.props;
const { showLoading, isDonating } = this.props;
const { enableSettings } = this.state;
if (showLoading) {
return <Loader fullScreen={true} />;
@ -81,7 +94,32 @@ export class DonatePage extends Component {
</Row>
<Row>
<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 md={6}>
<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);
}
export function postCreateHmacHash(body) {
return post(`/donate/create-hmac-hash`, body);
}
export function putUpdateLegacyCert(body) {
return post('/update-my-projects', body);
}

View File

@ -19,6 +19,14 @@ export const stripeScriptLoader = 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 = () =>
scriptLoader(
'mathjax',

View File

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

View File

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