From 4f77da02be4aa3ac8250c26dd5b6a32484d04e1e Mon Sep 17 00:00:00 2001 From: Stuart Taylor Date: Thu, 7 Jun 2018 22:35:06 +0100 Subject: [PATCH] feat(donate): Add donate api (#17459) * feat(donate): Add donate api * feat(donation): Add ability to track donations via email --- common/models/user.js | 19 ++++ common/models/user.json | 11 +++ config/secrets.js | 7 +- package-lock.json | 10 ++ package.json | 1 + sample.env | 4 + server/boot/donate.js | 121 +++++++++++++++++++++++++ server/model-config.json | 44 +++++---- server/models/donation.js | 15 +++ server/models/donation.json | 68 ++++++++++++++ server/utils/getDynamicPropsForUser.js | 52 +++++++++++ server/utils/publicUserProps.js | 1 + 12 files changed, 332 insertions(+), 21 deletions(-) create mode 100644 server/boot/donate.js create mode 100644 server/models/donation.js create mode 100644 server/models/donation.json create mode 100644 server/utils/getDynamicPropsForUser.js diff --git a/common/models/user.js b/common/models/user.js index fb53ab216e..0ab7363272 100644 --- a/common/models/user.js +++ b/common/models/user.js @@ -518,6 +518,25 @@ module.exports = function(User) { )({ ttl }); }; + User.prototype.createDonation = function createDonation(donation = {}) { + return Observable.fromNodeCallback( + this.donations.create.bind(this.donations) + )(donation) + .do(() => this.update$({ + $set: { + isDonating: true + }, + $push: { + donationEmails: donation.email + } + }) + ) + .do(() => { + this.isDonating = true; + this.donationEmails = [ ...this.donationEmails, donation.email ]; + }); + }; + User.prototype.getEncodedEmail = function getEncodedEmail(email) { if (!email) { return null; diff --git a/common/models/user.json b/common/models/user.json index db607a09b4..33fb10662b 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -262,10 +262,21 @@ } }, "default": {} + }, + "donationEmails": [ "string" ], + "isDonating": { + "type": "boolean", + "default": false, + "description": "Does the camper have an active donation" } }, "validations": [], "relations": { + "donations": { + "type": "hasMany", + "modal": "donation", + "foreignKey": "" + }, "credentials": { "type": "hasMany", "model": "userCredential", diff --git a/config/secrets.js b/config/secrets.js index fa10a9a349..a5d7f27c18 100644 --- a/config/secrets.js +++ b/config/secrets.js @@ -44,5 +44,10 @@ module.exports = { }, slackHook: process.env.SLACK_WEBHOOK, - cookieSecret: process.env.COOKIE_SECRET + cookieSecret: process.env.COOKIE_SECRET, + + stripe: { + public: process.env.STRIPE_PUBLIC, + secret: process.env.STRIPE_SECRET + } }; diff --git a/package-lock.json b/package-lock.json index fc64c7c9d3..aff4b36115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17922,6 +17922,16 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", "integrity": "sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=" }, + "stripe": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-6.1.0.tgz", + "integrity": "sha512-RqWyL2qwJinK1QlSwbZdVDw6OSo0Skg9nROTGgo5Oas6H8AqhGOUMSUHXD6OFKE7RODomNrXX+OHUAwllXqRsA==", + "requires": { + "lodash.isplainobject": "4.0.6", + "qs": "6.5.1", + "safe-buffer": "5.1.1" + } + }, "strong-error-handler": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strong-error-handler/-/strong-error-handler-2.3.0.tgz", diff --git a/package.json b/package.json index cde2158dc4..0bfeef9d0f 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "sanitize-html": "^1.11.1", "snyk": "^1.68.1", "store": "git+https://github.com/berkeleytrue/store.js.git#feature/noop-server", + "stripe": "^6.1.0", "uuid": "^3.0.1", "validator": "^9.4.0" }, diff --git a/sample.env b/sample.env index 4eef54c3e6..9349f20031 100644 --- a/sample.env +++ b/sample.env @@ -26,6 +26,10 @@ AUTH0_DOMAIN=stuff SESSION_SECRET=secretstuff COOKIE_SECRET='this is a secret' +JWT_SECRET='a very long secret' + +STRIPE_PUBLIC=pk_from_stipe_dashboard +STRIPE_SECRET=sk_from_stipe_dashboard PEER=stuff DEBUG=true diff --git a/server/boot/donate.js b/server/boot/donate.js new file mode 100644 index 0000000000..569464768a --- /dev/null +++ b/server/boot/donate.js @@ -0,0 +1,121 @@ +import Stripe from 'stripe'; + +import keys from '../../config/secrets'; + +const stripe = Stripe(keys.stripe.secret); + +const subscriptionPlans = [500, 1000, 3500, 5000, 25000].reduce( + (accu, current) => ({ + ...accu, + [current]: { + amount: current, + interval: 'month', + product: { + name: + 'Monthly Donation to freeCodeCamp.org - ' + + `Thank you ($${current / 100})` + }, + currency: 'usd', + id: `monthly-donation-${current}` + } + }), + {} +); + +function createStripePlan(plan) { + stripe.plans.create(plan, function(err) { + if (err) { + console.log(err); + throw err; + } + console.log(`${plan.id} created`); + return; + }); +} + +stripe.plans.list({}, function(err, plans) { + if (err) { + throw err; + } + const requiredPlans = Object.keys(subscriptionPlans).map( + key => subscriptionPlans[key].id + ); + const availablePlans = plans.data.map(plan => plan.id); + requiredPlans.forEach(planId => { + if (!availablePlans.includes(planId)) { + const key = planId.split('-').slice(-1)[0]; + createStripePlan(subscriptionPlans[key]); + } + }); +}); + +export default function donateBoot(app) { + const { User } = app.models; + const api = app.loopback.Router(); + const donateRouter = app.loopback.Router(); + + function createStripeDonation(req, res) { + const { user, body } = req; + + if (!body || !body.amount) { + return res.status(400).send({ error: 'Amount Required' }); + } + + const { amount, token: {email, id} } = body; + + const fccUser = user ? + Promise.resolve(user) : + User.create$({ email }).toPromise(); + + let donatingUser = {}; + let donation = { + email, + amount, + provider: 'stripe', + startDate: new Date(Date.now()).toISOString() + }; + + return fccUser.then( + user => { + donatingUser = user; + return stripe.customers + .create({ + email, + card: id + }); + }) + .then(customer => { + donation.customerId = customer.id; + return stripe.subscriptions.create({ + customer: customer.id, + items: [ + { + plan: `monthly-donation-${amount}` + } + ] + }); + }) + .then(subscription => { + donation.subscriptionId = subscription.id; + return res.send(subscription); + }) + .then(() => { + donatingUser.createDonation(donation).toPromise() + .catch(err => { + throw new Error(err); + }); + }) + .catch(err => { + if (err.type === 'StripeCardError') { + return res.status(402).send({ error: err.message }); + } + return res.status(500).send({ error: 'Donation Failed' }); + }); + } + + api.post('/charge-stripe', createStripeDonation); + + donateRouter.use('/donate', api); + app.use(donateRouter); + app.use('/external', donateRouter); +} diff --git a/server/model-config.json b/server/model-config.json index 4acea9fdba..3d65afe0fb 100644 --- a/server/model-config.json +++ b/server/model-config.json @@ -7,7 +7,11 @@ "./models" ] }, - "User": { + "about": { + "dataSource": "db", + "public": true + }, + "AuthToken": { "dataSource": "db", "public": false }, @@ -19,11 +23,15 @@ "dataSource": "db", "public": false }, - "RoleMapping": { + "block": { "dataSource": "db", - "public": false + "public": true }, - "Role": { + "challenge": { + "dataSource": "db", + "public": true + }, + "Donation": { "dataSource": "db", "public": false }, @@ -31,7 +39,7 @@ "dataSource": "mail", "public": false }, - "challenge": { + "flyer": { "dataSource": "db", "public": true }, @@ -43,10 +51,6 @@ "dataSource": "db", "public": true }, - "story": { - "dataSource": "db", - "public": true - }, "pledge": { "dataSource": "db", "public": true @@ -55,7 +59,15 @@ "dataSource": "db", "public": true }, - "user": { + "Role": { + "dataSource": "db", + "public": false + }, + "RoleMapping": { + "dataSource": "db", + "public": false + }, + "story": { "dataSource": "db", "public": true }, @@ -67,19 +79,11 @@ "dataSource": "db", "public": true }, - "flyer": { + "user": { "dataSource": "db", "public": true }, - "block": { - "dataSource": "db", - "public": true - }, - "about": { - "dataSource": "db", - "public": true - }, - "AuthToken": { + "User": { "dataSource": "db", "public": false } diff --git a/server/models/donation.js b/server/models/donation.js new file mode 100644 index 0000000000..6ccf65a886 --- /dev/null +++ b/server/models/donation.js @@ -0,0 +1,15 @@ +import { Observable } from 'rx'; + +export default function(Donation) { + Donation.on('dataSourceAttached', () => { + Donation.findOne$ = Observable.fromNodeCallback( + Donation.findOne.bind(Donation) + ); + Donation.prototype.validate$ = Observable.fromNodeCallback( + Donation.prototype.validate + ); + Donation.prototype.destroy$ = Observable.fromNodeCallback( + Donation.prototype.destroy + ); + }); +} diff --git a/server/models/donation.json b/server/models/donation.json new file mode 100644 index 0000000000..9bddc8caaa --- /dev/null +++ b/server/models/donation.json @@ -0,0 +1,68 @@ +{ + "name": "Donation", + "plural": "donations", + "description": "A representaion of a donation to freeCodeCamp", + "base": "PersistedModel", + "idInjection": true, + "options": { + "validateUpsert": true + }, + "properties": { + "email": { + "type": "string", + "required": true, + "description": "The email used to create the donation" + }, + "provider": { + "type": "string", + "required": true, + "description": "The payment handler, paypal/stripe etc..." + }, + "amount": { + "type": "number", + "required": true, + "description": "The donation amount in cents" + }, + "startDate": { + "type": "DateString", + "required": true + }, + "endDate": { + "type": "DateString" + }, + "subscriptionId": { + "type": "string", + "required": true, + "description": "The donation subscription id returned from the provider" + }, + "customerId": { + "type": "string", + "required": true, + "description": "The providers reference for the donator" + } + }, + "hidden": [], + "validations": [ + { + "amount": { + "type": "number", + "description": "Amount should be >= $1 (100c)", + "min": 100 + } + } + ], + "relations": { + "user": { + "type": "belongsTo", + "model": "user", + "foreignKey": "userId" + } + }, + "acls": [], + "scopes": {}, + "indexes" : {}, + "methods": [], + "remoting": {}, + "http": {} +} + diff --git a/server/utils/getDynamicPropsForUser.js b/server/utils/getDynamicPropsForUser.js new file mode 100644 index 0000000000..1fd2104d50 --- /dev/null +++ b/server/utils/getDynamicPropsForUser.js @@ -0,0 +1,52 @@ + +function getCompletedCertCount(user) { + return [ + 'isApisMicroservicesCert', + 'is2018DataVisCert', + 'isFrontEndLibsCert', + 'isInfosecQaCert', + 'isJsAlgoDataStructCert', + 'isRespWebDesignCert' + ].reduce((sum, key) => user[key] ? sum + 1 : sum, 0); +} + +function getLegacyCertCount(user) { + return [ + 'isFrontEndCert', + 'isBackEndCert', + 'isDataVisCert' + ].reduce((sum, key) => user[key] ? sum + 1 : sum, 0); +} + +export default function populateUser(db, user) { + return new Promise((resolve, reject) => { + let populatedUser = {...user}; + db.collection('user') + .aggregate([ + { $match: { _id: user.id } }, + { $project: { points: { $size: '$progressTimestamps' } } } + ], function(err, [{ points = 1 } = {}]) { + if (err) { return reject(err); } + user.points = points; + let completedChallengeCount = 0; + let completedProjectCount = 0; + if ('completedChallenges' in user) { + completedChallengeCount = user.completedChallenges.length; + user.completedChallenges.forEach(item => { + if ( + 'challengeType' in item && + (item.challengeType === 3 || item.challengeType === 4) + ) { + completedProjectCount++; + } + }); + } + populatedUser.completedChallengeCount = completedChallengeCount; + populatedUser.completedProjectCount = completedProjectCount; + populatedUser.completedCertCount = getCompletedCertCount(user); + populatedUser.completedLegacyCertCount = getLegacyCertCount(user); + populatedUser.completedChallenges = []; + return resolve(populatedUser); + }); + }); +} diff --git a/server/utils/publicUserProps.js b/server/utils/publicUserProps.js index a575d5e000..726cbe2f04 100644 --- a/server/utils/publicUserProps.js +++ b/server/utils/publicUserProps.js @@ -15,6 +15,7 @@ export const publicUserProps = [ 'isApisMicroservicesCert', 'isBackEndCert', 'isCheater', + 'isDonating', 'is2018DataVisCert', 'isDataVisCert', 'isFrontEndCert',