diff --git a/api-server/package-lock.json b/api-server/package-lock.json index c6638a1597..5912e19510 100644 --- a/api-server/package-lock.json +++ b/api-server/package-lock.json @@ -2302,21 +2302,11 @@ "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, "axios": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", - "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", - "dev": true, + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", "requires": { - "follow-redirects": "1.5.10", - "is-buffer": "^2.0.2" - }, - "dependencies": { - "is-buffer": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz", - "integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==", - "dev": true - } + "follow-redirects": "1.5.10" } }, "babel-core": { @@ -4707,7 +4697,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", - "dev": true, "requires": { "debug": "=3.1.0" }, @@ -4716,7 +4705,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, "requires": { "ms": "2.0.0" } @@ -4804,8 +4792,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -4826,14 +4813,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4848,20 +4833,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -4978,8 +4960,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -4991,7 +4972,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5006,7 +4986,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5014,14 +4993,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -5040,7 +5017,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5121,8 +5097,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -5134,7 +5109,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5220,8 +5194,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -5257,7 +5230,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5277,7 +5249,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5321,14 +5292,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, diff --git a/api-server/package.json b/api-server/package.json index 44f7aeb94a..f07c62f6a7 100644 --- a/api-server/package.json +++ b/api-server/package.json @@ -16,6 +16,7 @@ "@freecodecamp/loopback-component-passport": "^1.1.0", "accepts": "^1.3.7", "auth0-js": "^9.11.3", + "axios": "^0.19.2", "body-parser": "^1.19.0", "chai": "~3.4.1", "compression": "^1.7.4", diff --git a/api-server/server/boot/donate.js b/api-server/server/boot/donate.js index 092adb7c36..de445ffc3e 100644 --- a/api-server/server/boot/donate.js +++ b/api-server/server/boot/donate.js @@ -3,15 +3,27 @@ import debug from 'debug'; import crypto from 'crypto'; import { isEmail, isNumeric } from 'validator'; +import { + getAsyncPaypalToken, + verifyWebHook, + updateUser, + verifyWebHookType +} from '../utils/donation'; import { durationKeysConfig, donationOneTimeConfig, - donationSubscriptionConfig + donationSubscriptionConfig, + paypalConfig } from '../../../config/donation-settings'; import keys from '../../../config/secrets'; const log = debug('fcc:boot:donate'); +const paypalWebhookId = + process.env.FREECODECAMP_NODE_ENV === 'production' + ? paypalConfig.production.webhookId + : paypalConfig.development.webhookId; + export default function donateBoot(app, done) { let stripe = false; const api = app.loopback.Router(); @@ -243,27 +255,92 @@ export default function donateBoot(app, done) { .send({ error: 'Donation failed due to a server error.' }) ); } + function addDonation(req, res) { + const { user, body } = req; - const pubKey = keys.stripe.public; + 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) + .then(token => verifyWebHook(headers, body, token, paypalWebhookId)) + .then(hookBody => updateUser(hookBody, app)) + .then(() => res.status(200).json({ message: 'received hook' })) + .catch(err => { + log(err.message); + return res.status(200).json({ message: 'received hook' }); + }); + } + + const stripeKey = keys.stripe.public; const secKey = keys.stripe.secret; + const paypalKey = keys.paypal.client; + const paypalSec = keys.paypal.secret; const hmacKey = keys.servicebot.hmacKey; - const secretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard'; - const publicInvalid = !pubKey || pubKey === 'pk_from_stripe_dashboard'; + 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'; const hmacKeyInvalid = !hmacKey || hmacKey === 'secret_key_from_servicebot_dashboard'; - - if (secretInvalid || publicInvalid || hmacKeyInvalid) { + const paypalInvalid = paypalPublicInvalid || paypalSecretInvalid; + const stripeInvalid = stripeSecretInvalid || stripPublicInvalid; + if (stripeInvalid) { if (process.env.FREECODECAMP_NODE_ENV === 'production') { throw new Error('Stripe API keys are required to boot the server!'); } console.info('No Stripe API keys were found, moving on...'); - done(); } else { api.post('/charge-stripe', createStripeDonation); api.post('/create-hmac-hash', createHmacHash); - donateRouter.use('/donate', api); - app.use(donateRouter); - app.use('/internal', donateRouter); + } + if (paypalInvalid) { + if (process.env.FREECODECAMP_NODE_ENV === 'production') { + throw new Error('PayPal API keys are required to boot the server!'); + } + console.info('No PayPal API keys were found, moving on...'); + } else { + api.post('/update-paypal', updatePaypal); + api.post('/add-donation', addDonation); + } + if (hmacKeyInvalid) { + if (process.env.FREECODECAMP_NODE_ENV === 'production') { + throw new Error('Servicebot HMAC key is required to boot the server!'); + } + console.info('No servicebot HMAC key was found, moving on...'); + } + donateRouter.use('/donate', api); + app.use(donateRouter); + app.use('/internal', donateRouter); + + if (stripeInvalid) { + done(); + } else { connectToStripe().then(done); } } diff --git a/api-server/server/boot_tests/fixtures.js b/api-server/server/boot_tests/fixtures.js index 0bf9759a9f..2e60053bfa 100644 --- a/api-server/server/boot_tests/fixtures.js +++ b/api-server/server/boot_tests/fixtures.js @@ -1,3 +1,7 @@ +/* global jest*/ +import { isEqual } from 'lodash'; +import { isEmail } from 'validator'; + export const firstChallengeUrl = '/learn/the/first/challenge'; export const requestedChallengeUrl = '/learn/my/actual/challenge'; @@ -62,15 +66,55 @@ export const mockCompletedChallenges = [ } ]; export const mockUserID = '5c7d892aff9777c8b1c1a95e'; + +export const createUserMockFn = jest.fn(); +export const createDonationMockFn = jest.fn(); +export const updateDonationAttr = jest.fn(); +export const updateUserAttr = jest.fn(); export const mockUser = { id: mockUserID, username: 'camperbot', currentChallengeId: '123abc', + email: 'donor@freecodecamp.com', timezone: 'UTC', completedChallenges: mockCompletedChallenges, - progressTimestamps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + progressTimestamps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + isDonating: true, + donationEmails: ['donor@freecodecamp.com', 'donor@freecodecamp.com'], + createDonation: donation => { + createDonationMockFn(donation); + return mockObservable; + }, + updateAttributes: updateUserAttr }; +const mockObservable = { + toPromise: () => Promise.resolve('result') +}; + +export const mockDonation = { + id: '5e5f8eda5ed7be2b54e18718', + email: 'donor@freecodecamp.com', + provider: 'paypal', + amount: 500, + duration: 'month', + startDate: { + _when: '2018-11-01T00:00:00.000Z', + _date: '2018-11-01T00:00:00.000Z' + }, + subscriptionId: 'I-BA1ATBNF8T3P', + userId: mockUserID, + updateAttributes: updateDonationAttr +}; + +export function createNewUserFromEmail(email) { + const newMockUser = mockUser; + newMockUser.email = email; + newMockUser.username = 'camberbot2'; + newMockUser.ID = '5c7d892aff9888c8b1c1a95e'; + return newMockUser; +} + export const mockApp = { models: { Challenge: { @@ -83,12 +127,31 @@ export const mockApp = { : cb(new Error('challenge not found')); } }, + Donation: { + findOne(query, cb) { + return isEqual(query, matchSubscriptionIdQuery) + ? cb(null, mockDonation) + : cb(Error('No Donation')); + } + }, User: { findById(id, cb) { if (id === mockUser.id) { return cb(null, mockUser); } return cb(Error('No user')); + }, + findOne(query, cb) { + if (isEqual(query, matchEmailQuery) || isEqual(query, matchUserIdQuery)) + return cb(null, mockUser); + return cb(null, null); + }, + create(query, cb) { + if (!isEmail(query.email)) return cb(new Error('email not valid')); + else if (query.email === mockUser.email) + return cb(new Error('user exist')); + createUserMockFn(); + return Promise.resolve(createNewUserFromEmail(query.email)); } } } @@ -96,6 +159,16 @@ export const mockApp = { export const mockGetFirstChallenge = () => firstChallengeUrl; +export const matchEmailQuery = { + where: { email: mockUser.email } +}; +export const matchSubscriptionIdQuery = { + where: { subscriptionId: mockDonation.subscriptionId } +}; +export const matchUserIdQuery = { + where: { id: mockUser.id } +}; + export const firstChallengeQuery = { // first challenge of the first block of the first superBlock where: { challengeOrder: 0, superOrder: 1, order: 0 } diff --git a/api-server/server/middlewares/csurf.js b/api-server/server/middlewares/csurf.js index 045ad68c7b..6b15345c19 100644 --- a/api-server/server/middlewares/csurf.js +++ b/api-server/server/middlewares/csurf.js @@ -6,6 +6,7 @@ export default function() { domain: process.env.COOKIE_DOMAIN || 'localhost' } }); + // Note: paypal webhook goes through /internal return function csrf(req, res, next) { const path = req.path.split('/')[1]; if (/(^api$|^unauthenticated$|^internal$|^p$)/.test(path)) { diff --git a/api-server/server/middlewares/request-authorization.js b/api-server/server/middlewares/request-authorization.js index 36d2baaf23..e2465a57da 100644 --- a/api-server/server/middlewares/request-authorization.js +++ b/api-server/server/middlewares/request-authorization.js @@ -17,8 +17,14 @@ const apiProxyRE = /^\/internal\/|^\/external\//; const newsShortLinksRE = /^\/internal\/n\/|^\/internal\/p\?/; const loopbackAPIPathRE = /^\/internal\/api\//; const showCertRe = /^\/internal\/certificate\/showCert\//; +const updatePaypalRe = /^\/internal\/donate\/update-paypal/; -const _whiteListREs = [newsShortLinksRE, loopbackAPIPathRE, showCertRe]; +const _whiteListREs = [ + newsShortLinksRE, + loopbackAPIPathRE, + showCertRe, + updatePaypalRe +]; export function isWhiteListedPath(path, whiteListREs = _whiteListREs) { return whiteListREs.some(re => re.test(path)); diff --git a/api-server/server/utils/__mocks__/donation.js b/api-server/server/utils/__mocks__/donation.js new file mode 100644 index 0000000000..329bfd0c25 --- /dev/null +++ b/api-server/server/utils/__mocks__/donation.js @@ -0,0 +1,214 @@ +/* eslint-disable camelcase */ +export const mockCancellationHook = { + headers: { + host: 'a47fb0f4.ngrok.io', + accept: '*/*', + 'paypal-transmission-id': '2e24bc40-61d1-11ea-8ac4-7d4e2605c70c', + 'paypal-transmission-time': '2020-03-09T06:42:43Z', + 'paypal-transmission-sig': 'ODCa4gXmfnxkNga1t9p2HTIWFjlTj68P7MhueQd', + 'paypal-auth-version': 'v2', + 'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs', + 'paypal-auth-algo': 'SHA256withRSA', + 'content-type': 'application/json', + 'user-agent': 'PayPal/AUHD-214.0-54280748', + 'correlation-id': 'c3823d4c07ce5', + cal_poolstack: 'amqunphttpdeliveryd:UNPHTTPDELIVERY', + client_pid: '23853', + 'content-length': '1706', + 'x-forwarded-proto': 'https', + 'x-forwarded-for': '173.0.82.126' + }, + body: { + id: 'WH-1VF24938EU372274X-83540367M0110254R', + event_version: '1.0', + create_time: '2020-03-06T15:34:50.000Z', + resource_type: 'subscription', + resource_version: '2.0', + event_type: 'BILLING.SUBSCRIPTION.CANCELLED', + summary: 'Subscription cancelled', + resource: { + shipping_amount: { currency_code: 'USD', value: '0.0' }, + start_time: '2020-03-05T08:00:00Z', + update_time: '2020-03-09T06:42:09Z', + quantity: '1', + subscriber: { + name: [Object], + email_address: 'sb-zdry81054163@personal.example.com', + payer_id: '82PVXVLDAU3E8', + shipping_address: [Object] + }, + billing_info: { + outstanding_balance: [Object], + cycle_executions: [Array], + last_payment: [Object], + next_billing_time: '2020-04-05T10:00:00Z', + failed_payments_count: 0 + }, + create_time: '2020-03-06T07:34:50Z', + links: [[Object]], + id: 'I-BA1ATBNF8T3P', + plan_id: 'P-6VP46874PR423771HLZDKFBA', + status: 'CANCELLED', + status_update_time: '2020-03-09T06:42:09Z' + }, + links: [ + { + href: + 'https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-1VF24938EU372274X-83540367M0110254R', + rel: 'self', + method: 'GET' + }, + { + href: + 'https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-1VF24938EU372274X-83540367M0110254R/resend', + rel: 'resend', + method: 'POST' + } + ] + } +}; +export const mockActivationHook = { + headers: { + host: 'a47fb0f4.ngrok.io', + accept: '*/*', + 'paypal-transmission-id': '22103660-5f7d-11ea-8ac4-7d4e2605c70c', + 'paypal-transmission-time': '2020-03-06T07:36:03Z', + 'paypal-transmission-sig': + 'a;sldfn;lqwjhepjtn12l3n5123mnpu1i-sc-_+++dsflqenwpk1n234uthmsqwr123', + 'paypal-auth-version': 'v2', + 'paypal-cert-url': + 'https://api.sandbox.paypal.com/v1/notifications/certs/CERT-360caa42-fca2a594-1d93a270', + 'paypal-auth-algo': 'SHASHASHA', + 'content-type': 'application/json', + 'user-agent': 'PayPal/AUHD-214.0-54280748', + 'correlation-id': 'e0b25772e11af', + client_pid: '14973', + 'content-length': '2201', + 'x-forwarded-proto': 'https', + 'x-forwarded-for': '173.0.82.126' + }, + body: { + id: 'WH-77687562XN25889J8-8Y6T55435R66168T6', + create_time: '2018-19-12T22:20:32.000Z', + resource_type: 'subscription', + event_type: 'BILLING.SUBSCRIPTION.ACTIVATED', + summary: 'A billing agreement was activated.', + resource: { + quantity: '20', + subscriber: { + name: { + given_name: 'John', + surname: 'Doe' + }, + email_address: 'donor@freecodecamp.com', + shipping_address: { + name: { + full_name: 'John Doe' + }, + address: { + address_line_1: '2211 N First Street', + address_line_2: 'Building 17', + admin_area_2: 'San Jose', + admin_area_1: 'CA', + postal_code: '95131', + country_code: 'US' + } + } + }, + create_time: '2018-12-10T21:20:49Z', + shipping_amount: { + currency_code: 'USD', + value: '10.00' + }, + start_time: '2018-11-01T00:00:00Z', + update_time: '2018-12-10T21:20:49Z', + billing_info: { + outstanding_balance: { + currency_code: 'USD', + value: '10.00' + }, + cycle_executions: [ + { + tenure_type: 'TRIAL', + sequence: 1, + cycles_completed: 1, + cycles_remaining: 0, + current_pricing_scheme_version: 1 + }, + { + tenure_type: 'REGULAR', + sequence: 2, + cycles_completed: 1, + cycles_remaining: 0, + current_pricing_scheme_version: 2 + } + ], + last_payment: { + amount: { + currency_code: 'USD', + value: '500.00' + }, + time: '2018-12-01T01:20:49Z' + }, + next_billing_time: '2019-01-01T00:20:49Z', + final_payment_time: '2020-01-01T00:20:49Z', + failed_payments_count: 2 + }, + links: [ + { + href: + 'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G', + rel: 'self', + method: 'GET' + }, + { + href: + 'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G', + rel: 'edit', + method: 'PATCH' + }, + { + href: + 'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/suspend', + rel: 'suspend', + method: 'POST' + }, + { + href: + 'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/cancel', + rel: 'cancel', + method: 'POST' + }, + { + href: + 'https://api.paypal.com/v1/billing/subscriptions/I-BW452GLLEP1G/capture', + rel: 'capture', + method: 'POST' + } + ], + id: 'I-BW452GLLEP1G', + plan_id: 'P-5ML4271244454362WXNWU5NQ', + auto_renewal: true, + status: 'ACTIVE', + status_update_time: '2018-12-10T21:20:49Z' + }, + links: [ + { + href: + 'https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J8-8Y6T55435R66168T6', + rel: 'self', + method: 'GET', + encType: 'application/json' + }, + { + href: + 'https://api.paypal.com/v1/notifications/webhooks-events/WH-77687562XN25889J8-8Y6T55435R66168T6/resend', + rel: 'resend', + method: 'POST', + encType: 'application/json' + } + ], + event_version: '1.0', + resource_version: '2.0' + } +}; diff --git a/api-server/server/utils/donation-uttils.test.js b/api-server/server/utils/donation-uttils.test.js new file mode 100644 index 0000000000..7342b036d6 --- /dev/null +++ b/api-server/server/utils/donation-uttils.test.js @@ -0,0 +1,169 @@ +/* eslint-disable camelcase */ +/* global describe it expect */ +/* global jest*/ + +import axios from 'axios'; +import keys from '../../../config/secrets'; +import { + getAsyncPaypalToken, + verifyWebHook, + updateUser, + capitalizeKeys, + createDonationObj +} from './donation'; +import { mockActivationHook, mockCancellationHook } from './__mocks__/donation'; +import { + mockApp, + createDonationMockFn, + createUserMockFn, + updateDonationAttr, + updateUserAttr +} from '../boot_tests/fixtures'; + +jest.mock('axios'); + +const sandBoxSubdomain = + process.env.FREECODECAMP_NODE_ENV === 'production' ? '' : 'sandbox.'; + +const verificationUrl = `https://api.${sandBoxSubdomain}paypal.com/v1/notifications/verify-webhook-signature`; +const tokenUrl = `https://api.${sandBoxSubdomain}paypal.com/v1/oauth2/token`; +const { + body: activationHookBody, + headers: activationHookHeaders +} = mockActivationHook; + +describe('donation', () => { + describe('getAsyncPaypalToken', () => { + it('call paypal api for token ', async () => { + const res = { + data: { + access_token: 'token' + } + }; + + axios.post.mockImplementationOnce(() => Promise.resolve(res)); + + await expect(getAsyncPaypalToken()).resolves.toEqual( + res.data.access_token + ); + + expect(axios.post).toHaveBeenCalledTimes(1); + expect(axios.post).toHaveBeenCalledWith(tokenUrl, null, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + auth: { + username: keys.paypal.client, + password: keys.paypal.secret + }, + params: { + grant_type: 'client_credentials' + } + }); + }); + }); + + describe('verifyWebHook', () => { + // normalize headers + capitalizeKeys(activationHookHeaders); + const mockWebhookId = 'qwdfq;3w12341dfa4'; + const mockAccessToken = '241231223$!@$#1243'; + const mockPayLoad = { + auth_algo: activationHookHeaders['PAYPAL-AUTH-ALGO'], + cert_url: activationHookHeaders['PAYPAL-CERT-URL'], + transmission_id: activationHookHeaders['PAYPAL-TRANSMISSION-ID'], + transmission_sig: activationHookHeaders['PAYPAL-TRANSMISSION-SIG'], + transmission_time: activationHookHeaders['PAYPAL-TRANSMISSION-TIME'], + webhook_id: mockWebhookId, + webhook_event: activationHookBody + }; + const failedVerificationErr = { + message: `Failed token verification.`, + type: 'FailedPaypalTokenVerificationError' + }; + const axiosOptions = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${mockAccessToken}` + } + }; + const successVerificationResponce = { + data: { + verification_status: 'SUCCESS' + } + }; + const failedVerificationResponce = { + data: { + verification_status: 'FAILED' + } + }; + + it('calls paypal for Webhook verification', async () => { + axios.post.mockImplementationOnce(() => + Promise.resolve(successVerificationResponce) + ); + + await expect( + verifyWebHook( + activationHookHeaders, + activationHookBody, + mockAccessToken, + mockWebhookId + ) + ).resolves.toEqual(activationHookBody); + + expect(axios.post).toHaveBeenCalledWith( + verificationUrl, + mockPayLoad, + axiosOptions + ); + }); + it('throws error if verification not successful', async () => { + axios.post.mockImplementationOnce(() => + Promise.resolve(failedVerificationResponce) + ); + + await expect( + verifyWebHook( + activationHookHeaders, + activationHookBody, + mockAccessToken, + mockWebhookId + ) + ).rejects.toEqual(failedVerificationErr); + }); + }); + + describe('updateUser', () => { + it('created a donation when a machting user found', () => { + updateUser(activationHookBody, mockApp); + expect(createDonationMockFn).toHaveBeenCalledTimes(1); + expect(createDonationMockFn).toHaveBeenCalledWith( + createDonationObj(activationHookBody) + ); + }); + it('create a user and donation when no machting user found', () => { + let newActivationHookBody = activationHookBody; + newActivationHookBody.resource.subscriber.email_address = + 'new@freecodecamp.org'; + updateUser(newActivationHookBody, mockApp); + expect(createUserMockFn).toHaveBeenCalledTimes(1); + }); + + it('modify user and donation records on cancellation', () => { + const { body: cancellationHookBody } = mockCancellationHook; + const { + resource: { status_update_time = new Date(Date.now()).toISOString() } + } = cancellationHookBody; + + updateUser(cancellationHookBody, mockApp); + expect(updateDonationAttr).toHaveBeenCalledWith({ + endDate: new Date(status_update_time).toISOString() + }); + + expect(updateUserAttr).toHaveBeenCalledWith({ + isDonating: false + }); + }); + }); +}); diff --git a/api-server/server/utils/donation.js b/api-server/server/utils/donation.js new file mode 100644 index 0000000000..822c7d95c3 --- /dev/null +++ b/api-server/server/utils/donation.js @@ -0,0 +1,182 @@ +/* eslint-disable camelcase */ +import axios from 'axios'; +import debug from 'debug'; +import keys from '../../../config/secrets'; + +const log = debug('fcc:boot:donate'); + +const sandBoxSubdomain = + process.env.FREECODECAMP_NODE_ENV === 'production' ? '' : 'sandbox.'; + +const verificationUrl = `https://api.${sandBoxSubdomain}paypal.com/v1/notifications/verify-webhook-signature`; +const tokenUrl = `https://api.${sandBoxSubdomain}paypal.com/v1/oauth2/token`; + +export async function getAsyncPaypalToken() { + const res = await axios.post(tokenUrl, null, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + auth: { + username: keys.paypal.client, + password: keys.paypal.secret + }, + params: { + grant_type: 'client_credentials' + } + }); + return res.data.access_token; +} + +export function capitalizeKeys(object) { + Object.keys(object).forEach(function(key) { + object[key.toUpperCase()] = object[key]; + }); +} + +export async function verifyWebHook(headers, body, token, webhookId) { + var webhookEventBody = typeof body === 'string' ? JSON.parse(body) : body; + + capitalizeKeys(headers); + + const payload = { + auth_algo: headers['PAYPAL-AUTH-ALGO'], + cert_url: headers['PAYPAL-CERT-URL'], + transmission_id: headers['PAYPAL-TRANSMISSION-ID'], + transmission_sig: headers['PAYPAL-TRANSMISSION-SIG'], + transmission_time: headers['PAYPAL-TRANSMISSION-TIME'], + webhook_id: webhookId, + webhook_event: webhookEventBody + }; + + const response = await axios.post(verificationUrl, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }); + + if (response.data.verification_status === 'SUCCESS') { + return body; + } else { + throw { + message: `Failed token verification.`, + type: 'FailedPaypalTokenVerificationError' + }; + } +} + +export function verifyWebHookType(req) { + // check if webhook type for creation + const { + body: { event_type } + } = req; + + if ( + event_type === 'BILLING.SUBSCRIPTION.ACTIVATED' || + event_type === 'BILLING.SUBSCRIPTION.CANCELLED' + ) + return req; + else + throw { + message: 'Webhook type is not supported', + type: 'UnsupportedWebhookType' + }; +} + +export const createAsyncUserDonation = (user, donation) => { + log(`Creating donation:${donation.subscriptionId}`); + user + .createDonation(donation) + .toPromise() + .catch(err => { + throw new Error(err); + }); +}; + +export function createDonationObj(body) { + const { + resource: { + id, + start_time, + subscriber: { email_address } = { + email_address: null + } + } + } = body; + + let donation = { + email: email_address, + amount: 500, + duration: 'month', + provider: 'paypal', + subscriptionId: id, + customerId: email_address, + startDate: new Date(start_time).toISOString() + }; + return donation; +} + +export function createDonation(body, app) { + const { User } = app.models; + const { + resource: { + subscriber: { email_address } = { + email_address: null + } + } + } = body; + + let donation = createDonationObj(body); + + let email = email_address; + return User.findOne({ where: { email } }, (err, user) => { + if (err) throw new Error(err); + if (!user) { + log(`Creating new user:${email}`); + return User.create({ email }) + .then(user => { + createAsyncUserDonation(user, donation); + }) + .catch(err => { + throw new Error(err); + }); + } + return createAsyncUserDonation(user, donation); + }); +} + +export async function cancelDonation(body, app) { + const { + resource: { id, status_update_time = new Date(Date.now()).toISOString() } + } = body; + const { User, Donation } = app.models; + Donation.findOne({ where: { subscriptionId: id } }, (err, donation) => { + if (err || !donation) throw Error(err); + const userId = donation.userId; + log(`Updating donation record: ${donation.subscriptionId}`); + donation.updateAttributes({ + endDate: new Date(status_update_time).toISOString() + }); + + User.findOne({ where: { id: userId } }, (err, user) => { + if (err || !user || !user.donationEmails) throw Error(err); + log('Updating user record for donation cancellation'); + user.updateAttributes({ + isDonating: false + }); + }); + }); +} + +export async function updateUser(body, app) { + const { event_type } = body; + if (event_type === 'BILLING.SUBSCRIPTION.ACTIVATED') { + createDonation(body, app); + } else if (event_type === 'BILLING.SUBSCRIPTION.CANCELLED') { + cancelDonation(body, app); + } else + throw { + message: 'Webhook type is not supported', + type: 'UnsupportedWebhookType' + }; +} diff --git a/client/package-lock.json b/client/package-lock.json index 44ee8c94f0..421ea76064 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -18944,6 +18944,48 @@ } } }, + "react-paypal-button-v2": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/react-paypal-button-v2/-/react-paypal-button-v2-2.6.1.tgz", + "integrity": "sha512-ia1zzdRgziSfnJ/UueM6i0omEocbv4cge3mBRmpA6WSyxsX6c501HWHVwvXMEbTDCPK5+0ZKewhBzf/wq618Cg==", + "requires": { + "prop-types": "^15.7.2", + "rimraf": "^3.0.0" + }, + "dependencies": { + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, + "react-is": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", + "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==" + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + } + } + }, "react-prop-types": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", diff --git a/client/package.json b/client/package.json index 27fa76140d..ce60fef8bf 100644 --- a/client/package.json +++ b/client/package.json @@ -56,6 +56,7 @@ "react-identicons": "^1.1.7", "react-instantsearch-dom": "^6.0.0-beta.0", "react-monaco-editor": "^0.31.0", + "react-paypal-button-v2": "^2.6.1", "react-redux": "^5.0.7", "react-reflex": "^3.0.18", "react-responsive": "^6.1.1", diff --git a/client/src/client-only-routes/ShowCertification.js b/client/src/client-only-routes/ShowCertification.js index 49d5ee388a..f22a17408b 100644 --- a/client/src/client-only-routes/ShowCertification.js +++ b/client/src/client-only-routes/ShowCertification.js @@ -137,12 +137,12 @@ class ShowCertification extends Component { this.setState({ isDonationDisplayed: false, isDonationClosed: true }); } - handleProcessing(duration, amount) { + handleProcessing(duration, amount, action = 'stripe form submission') { this.props.executeGA({ type: 'event', data: { category: 'donation', - action: 'certificate stripe form submission', + action: `certificate ${action}`, label: duration, value: amount } diff --git a/client/src/components/Donation/Donation.css b/client/src/components/Donation/Donation.css index 356daca465..3ca7e9908a 100644 --- a/client/src/components/Donation/Donation.css +++ b/client/src/components/Donation/Donation.css @@ -212,6 +212,10 @@ li.disabled > a { } } +.donation-modal { + font-family: 'Lato', sans-serif; +} + .donation-modal .btn-link:focus { outline-width: 1px; outline-style: solid; diff --git a/client/src/components/Donation/DonationModal.js b/client/src/components/Donation/DonationModal.js index 531545a3dc..8c938f747e 100644 --- a/client/src/components/Donation/DonationModal.js +++ b/client/src/components/Donation/DonationModal.js @@ -59,12 +59,16 @@ function DonateModal({ executeGA }) { const [closeLabel, setCloseLabel] = React.useState(false); - const handleProcessing = (duration, amount) => { + const handleProcessing = ( + duration, + amount, + action = 'stripe form submission' + ) => { executeGA({ type: 'event', data: { category: 'donation', - action: 'Modal strip form submission', + action: `Modal ${action}`, label: duration, value: amount } @@ -88,8 +92,8 @@ function DonateModal({ const donationText = ( - Become a supporter and help us create even more learning resources for - you. + Become a $5 / month supporter and help us create even more learning + resources for you and your family. ); const blockDonationText = ( diff --git a/client/src/components/Donation/MinimalDonateForm.js b/client/src/components/Donation/MinimalDonateForm.js index 487c266dbd..6949a579de 100644 --- a/client/src/components/Donation/MinimalDonateForm.js +++ b/client/src/components/Donation/MinimalDonateForm.js @@ -13,8 +13,12 @@ import { import { stripePublicKey } from '../../../../config/env.json'; import { stripeScriptLoader } from '../../utils/scriptLoaders'; import DonateFormChildViewForHOC from './DonateFormChildViewForHOC'; +import DonateCompletion from './DonateCompletion'; +import PaypalButton from './PaypalButton'; import { userSelector } from '../../redux'; +import { Spacer } from '../../components/helpers'; + import './Donation.css'; const propTypes = { @@ -33,6 +37,14 @@ const mapStateToProps = createSelector( }) ); +const initialState = { + donationState: { + processing: false, + success: false, + error: '' + } +}; + class MinimalDonateForm extends Component { constructor(...args) { super(...args); @@ -42,10 +54,13 @@ class MinimalDonateForm extends Component { this.state = { ...modalDefaultStateConfig, + ...initialState, isDonating: this.props.isDonating, stripe: null }; this.handleStripeLoad = this.handleStripeLoad.bind(this); + this.onDonationStateChange = this.onDonationStateChange.bind(this); + this.resetDonation = this.resetDonation.bind(this); } componentDidMount() { @@ -77,12 +92,54 @@ class MinimalDonateForm extends Component { } } + resetDonation() { + return this.setState({ ...initialState }); + } + + onDonationStateChange(success, processing, error) { + this.setState(state => ({ + ...state, + donationState: { + ...state.donationState, + processing: processing, + success: success, + error: error + } + })); + } + + renderCompletion(props) { + return ; + } + render() { const { donationAmount, donationDuration, stripe } = this.state; const { handleProcessing, defaultTheme } = this.props; + const { + donationState: { processing, success, error } + } = this.state; + if (processing || success || error) { + return this.renderCompletion({ + processing, + success, + error, + reset: this.resetDonation + }); + } return ( + + + + + + Or donate with credit card number. + + @@ -91,7 +148,7 @@ class MinimalDonateForm extends Component { donationAmount={donationAmount} donationDuration={donationDuration} getDonationButtonLabel={() => - `Confirm your donation of $5 per month` + `Confirm your donation of $5 / month` } handleProcessing={handleProcessing} /> diff --git a/client/src/components/Donation/PaypalButton.js b/client/src/components/Donation/PaypalButton.js new file mode 100644 index 0000000000..898624f36f --- /dev/null +++ b/client/src/components/Donation/PaypalButton.js @@ -0,0 +1,112 @@ +/* eslint-disable camelcase */ +/* global ENVIRONMENT */ + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { PayPalButton } from 'react-paypal-button-v2'; +import { paypalClientId } from '../../../config/env.json'; +import { verifySubscriptionPaypal } from '../../utils/ajax'; +import { paypalConfig } from '../../../../config/donation-settings'; +import { signInLoadingSelector, userSelector, executeGA } from '../../redux'; + +const paypalDurationPlans = + ENVIRONMENT === 'production' + ? paypalConfig.production.durationPlans + : paypalConfig.development.durationPlans; + +export class PaypalButton extends Component { + constructor(...props) { + super(...props); + this.state = { + planId: paypalDurationPlans.month['500'].planId + }; + this.handleApproval = this.handleApproval.bind(this); + } + + handleApproval = data => { + this.props.handleProcessing('month', 500, 'Paypal payment submission'); + this.props.onDonationStateChange(false, true, ''); + verifySubscriptionPaypal(data) + .then(response => { + const data = response && response.data; + this.props.onDonationStateChange( + true, + false, + data.error ? data.error : '' + ); + }) + .catch(error => { + const data = + error.response && error.response.data + ? error.response.data + : { + error: + 'Something is not right. Please contact team@freecodecamp.org' + }; + this.props.onDonationStateChange(false, false, data.error); + }); + }; + + render() { + return ( + { + executeGA({ + type: 'event', + data: { + category: 'Donation', + action: `Modal Paypal clicked` + } + }); + return actions.subscription.create({ + plan_id: this.state.planId + }); + }} + onApprove={data => { + this.handleApproval(data); + }} + onCancel={() => { + this.props.onDonationStateChange( + false, + false, + 'Payment has been canceled.' + ); + }} + onError={() => + this.props.onDonationStateChange(false, false, 'Please try again.') + } + options={{ + vault: true, + disableFunding: 'card', + clientId: paypalClientId + }} + style={{ + tagline: false, + height: 43 + }} + /> + ); + } +} + +const propTypes = { + handleProcessing: PropTypes.func, + isDonating: PropTypes.bool, + onDonationStateChange: PropTypes.func +}; + +const mapStateToProps = createSelector( + userSelector, + signInLoadingSelector, + ({ isDonating }, showLoading) => ({ + isDonating, + showLoading + }) +); + +PaypalButton.displayName = 'PaypalButton'; +PaypalButton.propTypes = propTypes; + +export default connect(mapStateToProps)(PaypalButton); diff --git a/client/src/pages/certification.css b/client/src/pages/certification.css index c175c4fdef..657d2f4e83 100644 --- a/client/src/pages/certification.css +++ b/client/src/pages/certification.css @@ -42,6 +42,10 @@ padding: 20px 0px; } +.certificate-outer-wrapper .donation-section hr { + border: 1px solid var(--gray-10); +} + .certificate-outer-wrapper .donation-completion .btn { background-color: var(--gray-15); border-color: var(--gray-85); @@ -61,6 +65,11 @@ color: var(--quaternary-color); } +.certificate-outer-wrapper .donation-section, +.certificate-outer-wrapper .donation-section p { + font-family: 'Lato', sans-serif; +} + .certification-namespace header { width: 100%; height: 140px; diff --git a/client/src/pages/donate.js b/client/src/pages/donate.js index a60e98b3fc..dfa3d01980 100644 --- a/client/src/pages/donate.js +++ b/client/src/pages/donate.js @@ -12,6 +12,7 @@ import DonateForm from '../components/Donation/DonateForm'; import DonateText from '../components/Donation/DonateText'; import { signInLoadingSelector, userSelector, executeGA } from '../redux'; import { stripeScriptLoader } from '../utils/scriptLoaders'; +import { PaypalButton } from '../components/Donation/PaypalButton'; const propTypes = { executeGA: PropTypes.func, @@ -118,6 +119,9 @@ export class DonatePage extends Component { + + + {isDonating ? ( diff --git a/client/src/utils/ajax.js b/client/src/utils/ajax.js index f9c77d1169..973ca7597b 100644 --- a/client/src/utils/ajax.js +++ b/client/src/utils/ajax.js @@ -54,6 +54,10 @@ export function postChargeStripe(body) { return post('/donate/charge-stripe', body); } +export function verifySubscriptionPaypal(body) { + return post('/donate/add-donation', body); +} + export function postCreateHmacHash(body) { return post(`/donate/create-hmac-hash`, body); } diff --git a/client/src/utils/handled-error.test.js b/client/src/utils/handled-error.test.js index 60751d43ac..08a6866a33 100644 --- a/client/src/utils/handled-error.test.js +++ b/client/src/utils/handled-error.test.js @@ -1,4 +1,4 @@ -/* global describe it expect */ +/* global expect */ import { isObject } from 'lodash'; import sinon from 'sinon'; import { diff --git a/config/donation-settings.js b/config/donation-settings.js index 89b2cbc755..32243571f4 100644 --- a/config/donation-settings.js +++ b/config/donation-settings.js @@ -39,6 +39,30 @@ const donationSubscriptionConfig = { } }; +// Shared paypal configuration +const paypalConfig = { + production: { + webhookId: '8AM40465WC915574A', + durationPlans: { + month: { + '500': { + planId: 'P-1L11422374370240ULZKX3PA' + } + } + } + }, + development: { + webhookId: '2UL63757DN298592C', + durationPlans: { + month: { + '500': { + planId: 'P-146249205C631091BLZKRHGA' + } + } + } + } +}; + module.exports = { durationsConfig, amountsConfig, @@ -47,5 +71,6 @@ module.exports = { durationKeysConfig, donationOneTimeConfig, donationSubscriptionConfig, - modalDefaultStateConfig + modalDefaultStateConfig, + paypalConfig }; diff --git a/config/env.js b/config/env.js index dee22df369..fc31c03148 100644 --- a/config/env.js +++ b/config/env.js @@ -20,7 +20,8 @@ const { STRIPE_PUBLIC_KEY: stripePublicKey, SERVICEBOT_ID: servicebotId, ALGOLIA_APP_ID: algoliaAppId, - ALGOLIA_API_KEY: algoliaAPIKey + ALGOLIA_API_KEY: algoliaAPIKey, + PAYPAL_CLIENT_ID: paypalClientId } = process.env; const locations = { @@ -49,5 +50,9 @@ module.exports = Object.assign(locations, { algoliaAPIKey: !algoliaAPIKey || algoliaAPIKey === 'Algolia api key from dashboard' ? null - : algoliaAPIKey + : algoliaAPIKey, + paypalClientId: + !paypalClientId || paypalClientId === 'id_from_paypal_dashboard' + ? null + : paypalClientId }); diff --git a/config/secrets.js b/config/secrets.js index 262067c5b9..6a2b8242dc 100644 --- a/config/secrets.js +++ b/config/secrets.js @@ -33,7 +33,11 @@ const { STRIPE_PUBLIC_KEY, STRIPE_SECRET_KEY, SERVICEBOT_ID, - SERVICEBOT_HMAC_SECRET_KEY + + SERVICEBOT_HMAC_SECRET_KEY, + + PAYPAL_CLIENT_ID, + PAYPAL_SECRET } = process.env; module.exports = { @@ -98,6 +102,11 @@ module.exports = { secret: STRIPE_SECRET_KEY }, + paypal: { + client: PAYPAL_CLIENT_ID, + secret: PAYPAL_SECRET + }, + servicebot: { servicebotId: SERVICEBOT_ID, hmacKey: SERVICEBOT_HMAC_SECRET_KEY diff --git a/sample.env b/sample.env index 020f6781d6..cf4740d902 100644 --- a/sample.env +++ b/sample.env @@ -23,6 +23,8 @@ SERVICEBOT_ID=servicebot_id_from_servicebot_dashboard SERVICEBOT_HMAC_SECRET_KEY=secret_key_from_servicebot_dashboard PAYPAL_SUPPORTERS=1703 +PAYPAL_CLIENT_ID=id_from_paypal_dashboard +PAYPAL_SECRET=secret_from_paypal_dashboard PEER=stuff DEBUG=true