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