2018-06-07 21:35:06 +00:00
|
|
|
import Stripe from 'stripe';
|
2019-02-07 13:33:18 +00:00
|
|
|
import debug from 'debug';
|
2019-11-13 14:10:49 +00:00
|
|
|
import crypto from 'crypto';
|
2019-11-06 13:32:20 +00:00
|
|
|
import { isEmail, isNumeric } from 'validator';
|
2019-02-07 13:33:18 +00:00
|
|
|
|
2020-03-13 09:25:57 +00:00
|
|
|
import {
|
|
|
|
getAsyncPaypalToken,
|
|
|
|
verifyWebHook,
|
|
|
|
updateUser,
|
|
|
|
verifyWebHookType
|
|
|
|
} from '../utils/donation';
|
2019-11-19 15:00:47 +00:00
|
|
|
import {
|
|
|
|
durationKeysConfig,
|
2019-12-17 22:45:55 +00:00
|
|
|
donationOneTimeConfig,
|
2020-03-16 09:02:35 +00:00
|
|
|
donationSubscriptionConfig
|
2019-11-19 15:00:47 +00:00
|
|
|
} from '../../../config/donation-settings';
|
2018-08-31 15:04:04 +00:00
|
|
|
import keys from '../../../config/secrets';
|
2018-06-07 21:35:06 +00:00
|
|
|
|
2019-02-07 13:33:18 +00:00
|
|
|
const log = debug('fcc:boot:donate');
|
|
|
|
|
2018-06-18 14:47:10 +00:00
|
|
|
export default function donateBoot(app, done) {
|
|
|
|
let stripe = false;
|
2020-03-20 20:09:29 +00:00
|
|
|
const { User } = app.models;
|
2018-06-07 21:35:06 +00:00
|
|
|
const api = app.loopback.Router();
|
2020-03-19 06:50:04 +00:00
|
|
|
const hooks = app.loopback.Router();
|
2018-06-07 21:35:06 +00:00
|
|
|
const donateRouter = app.loopback.Router();
|
2019-11-06 13:32:20 +00:00
|
|
|
|
|
|
|
const subscriptionPlans = Object.keys(
|
|
|
|
donationSubscriptionConfig.plans
|
|
|
|
).reduce(
|
|
|
|
(prevDuration, duration) =>
|
|
|
|
prevDuration.concat(
|
|
|
|
donationSubscriptionConfig.plans[duration].reduce(
|
|
|
|
(prevAmount, amount) =>
|
|
|
|
prevAmount.concat({
|
|
|
|
amount: amount,
|
|
|
|
interval: duration,
|
|
|
|
product: {
|
|
|
|
name: `${
|
|
|
|
donationSubscriptionConfig.duration[duration]
|
|
|
|
} Donation to freeCodeCamp.org - Thank you ($${amount / 100})`,
|
|
|
|
metadata: {
|
|
|
|
/* eslint-disable camelcase */
|
|
|
|
sb_service: `freeCodeCamp.org`,
|
|
|
|
sb_tier: `${
|
|
|
|
donationSubscriptionConfig.duration[duration]
|
|
|
|
} $${amount / 100} Donation`
|
|
|
|
/* eslint-enable camelcase */
|
|
|
|
}
|
|
|
|
},
|
|
|
|
currency: 'usd',
|
|
|
|
id: `${donationSubscriptionConfig.duration[
|
|
|
|
duration
|
|
|
|
].toLowerCase()}-donation-${amount}`
|
|
|
|
}),
|
|
|
|
[]
|
|
|
|
)
|
|
|
|
),
|
|
|
|
[]
|
2018-06-18 14:47:10 +00:00
|
|
|
);
|
|
|
|
|
2019-11-06 13:32:20 +00:00
|
|
|
function validStripeForm(amount, duration, email) {
|
|
|
|
return isEmail('' + email) &&
|
|
|
|
isNumeric('' + amount) &&
|
2019-11-19 15:00:47 +00:00
|
|
|
durationKeysConfig.includes(duration) &&
|
2019-11-06 13:32:20 +00:00
|
|
|
duration === 'onetime'
|
2019-12-17 22:45:55 +00:00
|
|
|
? donationOneTimeConfig.includes(amount)
|
2019-11-06 13:32:20 +00:00
|
|
|
: donationSubscriptionConfig.plans[duration];
|
|
|
|
}
|
|
|
|
|
2018-06-18 14:47:10 +00:00
|
|
|
function connectToStripe() {
|
|
|
|
return new Promise(function(resolve) {
|
|
|
|
// connect to stripe API
|
|
|
|
stripe = Stripe(keys.stripe.secret);
|
|
|
|
// parse stripe plans
|
2019-11-06 13:32:20 +00:00
|
|
|
stripe.plans.list({}, function(err, stripePlans) {
|
2018-06-18 14:47:10 +00:00
|
|
|
if (err) {
|
|
|
|
throw err;
|
|
|
|
}
|
2019-11-06 13:32:20 +00:00
|
|
|
const requiredPlans = subscriptionPlans.map(plan => plan.id);
|
|
|
|
const availablePlans = stripePlans.data.map(plan => plan.id);
|
2019-11-19 09:18:09 +00:00
|
|
|
if (process.env.STRIPE_CREATE_PLANS === 'true') {
|
|
|
|
requiredPlans.forEach(requiredPlan => {
|
|
|
|
if (!availablePlans.includes(requiredPlan)) {
|
|
|
|
createStripePlan(
|
|
|
|
subscriptionPlans.find(plan => plan.id === requiredPlan)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
log(`Skipping plan creation`);
|
|
|
|
}
|
2018-06-18 14:47:10 +00:00
|
|
|
});
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function createStripePlan(plan) {
|
2019-11-06 13:32:20 +00:00
|
|
|
log(`Creating subscription plan: ${plan.product.name}`);
|
2018-06-18 14:47:10 +00:00
|
|
|
stripe.plans.create(plan, function(err) {
|
|
|
|
if (err) {
|
2019-11-06 13:32:20 +00:00
|
|
|
log(err);
|
2018-06-18 14:47:10 +00:00
|
|
|
}
|
2019-11-06 13:32:20 +00:00
|
|
|
log(`Created plan with plan id: ${plan.id}`);
|
2019-02-06 14:19:58 +00:00
|
|
|
return;
|
|
|
|
});
|
2018-06-18 14:47:10 +00:00
|
|
|
}
|
2018-06-07 21:35:06 +00:00
|
|
|
|
|
|
|
function createStripeDonation(req, res) {
|
|
|
|
const { user, body } = req;
|
|
|
|
|
2019-02-18 19:32:49 +00:00
|
|
|
const {
|
|
|
|
amount,
|
2019-11-06 13:32:20 +00:00
|
|
|
duration,
|
2019-02-18 19:32:49 +00:00
|
|
|
token: { email, id }
|
|
|
|
} = body;
|
2018-06-07 21:35:06 +00:00
|
|
|
|
2019-11-06 13:32:20 +00:00
|
|
|
if (!validStripeForm(amount, duration, email)) {
|
2019-11-13 20:03:53 +00:00
|
|
|
return res.status(500).send({
|
|
|
|
error: 'The donation form had invalid values for this submission.'
|
|
|
|
});
|
2019-11-06 13:32:20 +00:00
|
|
|
}
|
|
|
|
|
2020-03-20 20:09:29 +00:00
|
|
|
const fccUser = user
|
|
|
|
? Promise.resolve(user)
|
|
|
|
: new Promise((resolve, reject) =>
|
|
|
|
User.findOrCreate(
|
|
|
|
{ where: { email } },
|
|
|
|
{ email },
|
|
|
|
(err, instance, isNew) => {
|
|
|
|
log('createing a new donating user instance: ', isNew);
|
|
|
|
if (err) {
|
|
|
|
return reject(err);
|
|
|
|
}
|
|
|
|
return resolve(instance);
|
|
|
|
}
|
|
|
|
)
|
|
|
|
);
|
|
|
|
|
2018-06-07 21:35:06 +00:00
|
|
|
let donatingUser = {};
|
|
|
|
let donation = {
|
|
|
|
email,
|
|
|
|
amount,
|
2019-11-06 13:32:20 +00:00
|
|
|
duration,
|
2018-06-07 21:35:06 +00:00
|
|
|
provider: 'stripe',
|
|
|
|
startDate: new Date(Date.now()).toISOString()
|
|
|
|
};
|
|
|
|
|
2019-11-06 13:32:20 +00:00
|
|
|
const createCustomer = user => {
|
|
|
|
donatingUser = user;
|
|
|
|
return stripe.customers.create({
|
|
|
|
email,
|
|
|
|
card: id
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const createSubscription = customer => {
|
|
|
|
donation.customerId = customer.id;
|
|
|
|
return stripe.subscriptions.create({
|
|
|
|
customer: customer.id,
|
|
|
|
items: [
|
|
|
|
{
|
|
|
|
plan: `${donationSubscriptionConfig.duration[
|
|
|
|
duration
|
|
|
|
].toLowerCase()}-donation-${amount}`
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const createOneTimeCharge = customer => {
|
|
|
|
donation.customerId = customer.id;
|
|
|
|
return stripe.charges.create({
|
|
|
|
amount: amount,
|
|
|
|
currency: 'usd',
|
|
|
|
customer: customer.id
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
const createAsyncUserDonation = () => {
|
|
|
|
donatingUser
|
|
|
|
.createDonation(donation)
|
|
|
|
.toPromise()
|
|
|
|
.catch(err => {
|
|
|
|
throw new Error(err);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-03-20 20:09:29 +00:00
|
|
|
return Promise.resolve(fccUser)
|
2020-01-08 21:07:50 +00:00
|
|
|
.then(nonDonatingUser => {
|
|
|
|
const { isDonating } = nonDonatingUser;
|
2020-03-20 20:09:29 +00:00
|
|
|
if (isDonating && duration !== 'onetime') {
|
2020-01-08 21:07:50 +00:00
|
|
|
throw {
|
2020-03-20 20:09:29 +00:00
|
|
|
message: `User already has active recurring donation(s).`,
|
2020-01-08 21:07:50 +00:00
|
|
|
type: 'AlreadyDonatingError'
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return nonDonatingUser;
|
|
|
|
})
|
2019-11-06 13:32:20 +00:00
|
|
|
.then(createCustomer)
|
2019-02-06 14:19:58 +00:00
|
|
|
.then(customer => {
|
2019-11-13 20:03:53 +00:00
|
|
|
return duration === 'onetime'
|
2019-11-06 13:32:20 +00:00
|
|
|
? createOneTimeCharge(customer).then(charge => {
|
|
|
|
donation.subscriptionId = 'one-time-charge-prefix-' + charge.id;
|
|
|
|
return res.send(charge);
|
|
|
|
})
|
|
|
|
: createSubscription(customer).then(subscription => {
|
|
|
|
donation.subscriptionId = subscription.id;
|
|
|
|
return res.send(subscription);
|
|
|
|
});
|
2019-02-06 14:19:58 +00:00
|
|
|
})
|
2019-11-06 13:32:20 +00:00
|
|
|
.then(createAsyncUserDonation)
|
2019-02-06 14:19:58 +00:00
|
|
|
.catch(err => {
|
2020-01-08 21:07:50 +00:00
|
|
|
if (
|
|
|
|
err.type === 'StripeCardError' ||
|
|
|
|
err.type === 'AlreadyDonatingError'
|
|
|
|
) {
|
2019-12-17 22:45:55 +00:00
|
|
|
return res.status(402).send({ error: err.message });
|
|
|
|
}
|
|
|
|
return res
|
|
|
|
.status(500)
|
|
|
|
.send({ error: 'Donation failed due to a server error.' });
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-11-13 14:10:49 +00:00
|
|
|
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.' })
|
|
|
|
);
|
|
|
|
}
|
2020-03-16 09:02:35 +00:00
|
|
|
|
2020-03-13 09:25:57 +00:00
|
|
|
function addDonation(req, res) {
|
|
|
|
const { user, body } = req;
|
|
|
|
|
|
|
|
if (!user || !body) {
|
|
|
|
return res
|
|
|
|
.status(500)
|
|
|
|
.send({ error: 'User must be signed in for this request.' });
|
|
|
|
}
|
|
|
|
return Promise.resolve(req)
|
|
|
|
.then(
|
|
|
|
user.updateAttributes({
|
|
|
|
isDonating: true
|
|
|
|
})
|
|
|
|
)
|
|
|
|
.then(() => res.status(200).json({ isDonating: true }))
|
|
|
|
.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)
|
|
|
|
.then(verifyWebHookType)
|
|
|
|
.then(getAsyncPaypalToken)
|
2020-03-16 09:02:35 +00:00
|
|
|
.then(token => verifyWebHook(headers, body, token, keys.paypal.webhookId))
|
2020-03-13 09:25:57 +00:00
|
|
|
.then(hookBody => updateUser(hookBody, app))
|
|
|
|
.catch(err => {
|
2020-03-19 06:50:04 +00:00
|
|
|
// Todo: This probably need to be thrown and caught in error handler
|
2020-03-13 09:25:57 +00:00
|
|
|
log(err.message);
|
2020-03-19 06:50:04 +00:00
|
|
|
})
|
|
|
|
.finally(() => res.status(200).json({ message: 'received paypal hook' }));
|
2020-03-13 09:25:57 +00:00
|
|
|
}
|
2019-11-13 14:10:49 +00:00
|
|
|
|
2020-03-13 09:25:57 +00:00
|
|
|
const stripeKey = keys.stripe.public;
|
2018-06-18 14:47:10 +00:00
|
|
|
const secKey = keys.stripe.secret;
|
2020-03-13 09:25:57 +00:00
|
|
|
const paypalKey = keys.paypal.client;
|
|
|
|
const paypalSec = keys.paypal.secret;
|
2019-11-13 14:10:49 +00:00
|
|
|
const hmacKey = keys.servicebot.hmacKey;
|
2020-03-13 09:25:57 +00:00
|
|
|
const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
|
|
|
|
const stripPublicInvalid =
|
|
|
|
!stripeKey || stripeKey === 'pk_from_stripe_dashboard';
|
|
|
|
|
|
|
|
const paypalSecretInvalid =
|
|
|
|
!paypalKey || paypalKey === 'id_from_paypal_dashboard';
|
|
|
|
const paypalPublicInvalid =
|
|
|
|
!paypalSec || paypalSec === 'secret_from_paypal_dashboard';
|
2019-11-13 14:10:49 +00:00
|
|
|
const hmacKeyInvalid =
|
|
|
|
!hmacKey || hmacKey === 'secret_key_from_servicebot_dashboard';
|
2020-03-13 09:25:57 +00:00
|
|
|
const paypalInvalid = paypalPublicInvalid || paypalSecretInvalid;
|
|
|
|
const stripeInvalid = stripeSecretInvalid || stripPublicInvalid;
|
2020-03-16 09:02:35 +00:00
|
|
|
|
|
|
|
if (stripeInvalid || paypalInvalid || hmacKeyInvalid) {
|
2019-08-18 19:49:40 +00:00
|
|
|
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
|
2020-03-16 09:02:35 +00:00
|
|
|
throw new Error('Donation API keys are required to boot the server!');
|
2018-06-18 14:47:10 +00:00
|
|
|
}
|
2020-03-16 09:02:35 +00:00
|
|
|
log('Donation disabled in development unless ALL test keys are provided');
|
|
|
|
done();
|
2018-06-18 14:47:10 +00:00
|
|
|
} else {
|
|
|
|
api.post('/charge-stripe', createStripeDonation);
|
2019-11-13 14:10:49 +00:00
|
|
|
api.post('/create-hmac-hash', createHmacHash);
|
2020-03-13 09:25:57 +00:00
|
|
|
api.post('/add-donation', addDonation);
|
2020-03-19 06:50:04 +00:00
|
|
|
hooks.post('/update-paypal', updatePaypal);
|
2020-03-16 09:02:35 +00:00
|
|
|
donateRouter.use('/donate', api);
|
2020-03-19 06:50:04 +00:00
|
|
|
donateRouter.use('/hooks', hooks);
|
2020-03-16 09:02:35 +00:00
|
|
|
app.use(donateRouter);
|
2018-06-18 14:47:10 +00:00
|
|
|
connectToStripe().then(done);
|
|
|
|
}
|
2018-06-07 21:35:06 +00:00
|
|
|
}
|