feat(donate): PayPal integration

This commit is contained in:
Ahmad Abdolsaheb
2020-03-13 12:25:57 +03:00
committed by Mrugesh Mohapatra
parent e3db423abf
commit 6c6eadfbe4
24 changed files with 1040 additions and 70 deletions

View File

@ -2302,21 +2302,11 @@
"integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ=="
}, },
"axios": { "axios": {
"version": "0.19.0", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
"integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
"dev": true,
"requires": { "requires": {
"follow-redirects": "1.5.10", "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
}
} }
}, },
"babel-core": { "babel-core": {
@ -4707,7 +4697,6 @@
"version": "1.5.10", "version": "1.5.10",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
"dev": true,
"requires": { "requires": {
"debug": "=3.1.0" "debug": "=3.1.0"
}, },
@ -4716,7 +4705,6 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"dev": true,
"requires": { "requires": {
"ms": "2.0.0" "ms": "2.0.0"
} }
@ -4804,8 +4792,7 @@
"ansi-regex": { "ansi-regex": {
"version": "2.1.1", "version": "2.1.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
@ -4826,14 +4813,12 @@
"balanced-match": { "balanced-match": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@ -4848,20 +4833,17 @@
"code-point-at": { "code-point-at": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"console-control-strings": { "console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"core-util-is": { "core-util-is": {
"version": "1.0.2", "version": "1.0.2",
@ -4978,8 +4960,7 @@
"inherits": { "inherits": {
"version": "2.0.3", "version": "2.0.3",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"ini": { "ini": {
"version": "1.3.5", "version": "1.3.5",
@ -4991,7 +4972,6 @@
"version": "1.0.0", "version": "1.0.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"number-is-nan": "^1.0.0" "number-is-nan": "^1.0.0"
} }
@ -5006,7 +4986,6 @@
"version": "3.0.4", "version": "3.0.4",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
} }
@ -5014,14 +4993,12 @@
"minimist": { "minimist": {
"version": "0.0.8", "version": "0.0.8",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"minipass": { "minipass": {
"version": "2.3.5", "version": "2.3.5",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"safe-buffer": "^5.1.2", "safe-buffer": "^5.1.2",
"yallist": "^3.0.0" "yallist": "^3.0.0"
@ -5040,7 +5017,6 @@
"version": "0.5.1", "version": "0.5.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
} }
@ -5121,8 +5097,7 @@
"number-is-nan": { "number-is-nan": {
"version": "1.0.1", "version": "1.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
@ -5134,7 +5109,6 @@
"version": "1.4.0", "version": "1.4.0",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"wrappy": "1" "wrappy": "1"
} }
@ -5220,8 +5194,7 @@
"safe-buffer": { "safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"safer-buffer": { "safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@ -5257,7 +5230,6 @@
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"code-point-at": "^1.0.0", "code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0", "is-fullwidth-code-point": "^1.0.0",
@ -5277,7 +5249,6 @@
"version": "3.0.1", "version": "3.0.1",
"bundled": true, "bundled": true,
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"ansi-regex": "^2.0.0" "ansi-regex": "^2.0.0"
} }
@ -5321,14 +5292,12 @@
"wrappy": { "wrappy": {
"version": "1.0.2", "version": "1.0.2",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
}, },
"yallist": { "yallist": {
"version": "3.0.3", "version": "3.0.3",
"bundled": true, "bundled": true,
"dev": true, "dev": true
"optional": true
} }
} }
}, },

View File

@ -16,6 +16,7 @@
"@freecodecamp/loopback-component-passport": "^1.1.0", "@freecodecamp/loopback-component-passport": "^1.1.0",
"accepts": "^1.3.7", "accepts": "^1.3.7",
"auth0-js": "^9.11.3", "auth0-js": "^9.11.3",
"axios": "^0.19.2",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"chai": "~3.4.1", "chai": "~3.4.1",
"compression": "^1.7.4", "compression": "^1.7.4",

View File

@ -3,15 +3,27 @@ import debug from 'debug';
import crypto from 'crypto'; import crypto from 'crypto';
import { isEmail, isNumeric } from 'validator'; import { isEmail, isNumeric } from 'validator';
import {
getAsyncPaypalToken,
verifyWebHook,
updateUser,
verifyWebHookType
} from '../utils/donation';
import { import {
durationKeysConfig, durationKeysConfig,
donationOneTimeConfig, donationOneTimeConfig,
donationSubscriptionConfig donationSubscriptionConfig,
paypalConfig
} from '../../../config/donation-settings'; } from '../../../config/donation-settings';
import keys from '../../../config/secrets'; import keys from '../../../config/secrets';
const log = debug('fcc:boot:donate'); 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) { export default function donateBoot(app, done) {
let stripe = false; let stripe = false;
const api = app.loopback.Router(); const api = app.loopback.Router();
@ -243,27 +255,92 @@ export default function donateBoot(app, done) {
.send({ error: 'Donation failed due to a server error.' }) .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 secKey = keys.stripe.secret;
const paypalKey = keys.paypal.client;
const paypalSec = keys.paypal.secret;
const hmacKey = keys.servicebot.hmacKey; const hmacKey = keys.servicebot.hmacKey;
const secretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard'; const stripeSecretInvalid = !secKey || secKey === 'sk_from_stripe_dashboard';
const publicInvalid = !pubKey || pubKey === 'pk_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 = const hmacKeyInvalid =
!hmacKey || hmacKey === 'secret_key_from_servicebot_dashboard'; !hmacKey || hmacKey === 'secret_key_from_servicebot_dashboard';
const paypalInvalid = paypalPublicInvalid || paypalSecretInvalid;
if (secretInvalid || publicInvalid || hmacKeyInvalid) { const stripeInvalid = stripeSecretInvalid || stripPublicInvalid;
if (stripeInvalid) {
if (process.env.FREECODECAMP_NODE_ENV === 'production') { if (process.env.FREECODECAMP_NODE_ENV === 'production') {
throw new Error('Stripe API keys are required to boot the server!'); throw new Error('Stripe API keys are required to boot the server!');
} }
console.info('No Stripe API keys were found, moving on...'); console.info('No Stripe API keys were found, moving on...');
done();
} else { } else {
api.post('/charge-stripe', createStripeDonation); api.post('/charge-stripe', createStripeDonation);
api.post('/create-hmac-hash', createHmacHash); api.post('/create-hmac-hash', createHmacHash);
donateRouter.use('/donate', api); }
app.use(donateRouter); if (paypalInvalid) {
app.use('/internal', donateRouter); 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); connectToStripe().then(done);
} }
} }

View File

@ -1,3 +1,7 @@
/* global jest*/
import { isEqual } from 'lodash';
import { isEmail } from 'validator';
export const firstChallengeUrl = '/learn/the/first/challenge'; export const firstChallengeUrl = '/learn/the/first/challenge';
export const requestedChallengeUrl = '/learn/my/actual/challenge'; export const requestedChallengeUrl = '/learn/my/actual/challenge';
@ -62,15 +66,55 @@ export const mockCompletedChallenges = [
} }
]; ];
export const mockUserID = '5c7d892aff9777c8b1c1a95e'; 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 = { export const mockUser = {
id: mockUserID, id: mockUserID,
username: 'camperbot', username: 'camperbot',
currentChallengeId: '123abc', currentChallengeId: '123abc',
email: 'donor@freecodecamp.com',
timezone: 'UTC', timezone: 'UTC',
completedChallenges: mockCompletedChallenges, 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 = { export const mockApp = {
models: { models: {
Challenge: { Challenge: {
@ -83,12 +127,31 @@ export const mockApp = {
: cb(new Error('challenge not found')); : cb(new Error('challenge not found'));
} }
}, },
Donation: {
findOne(query, cb) {
return isEqual(query, matchSubscriptionIdQuery)
? cb(null, mockDonation)
: cb(Error('No Donation'));
}
},
User: { User: {
findById(id, cb) { findById(id, cb) {
if (id === mockUser.id) { if (id === mockUser.id) {
return cb(null, mockUser); return cb(null, mockUser);
} }
return cb(Error('No user')); 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 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 = { export const firstChallengeQuery = {
// first challenge of the first block of the first superBlock // first challenge of the first block of the first superBlock
where: { challengeOrder: 0, superOrder: 1, order: 0 } where: { challengeOrder: 0, superOrder: 1, order: 0 }

View File

@ -6,6 +6,7 @@ export default function() {
domain: process.env.COOKIE_DOMAIN || 'localhost' domain: process.env.COOKIE_DOMAIN || 'localhost'
} }
}); });
// Note: paypal webhook goes through /internal
return function csrf(req, res, next) { return function csrf(req, res, next) {
const path = req.path.split('/')[1]; const path = req.path.split('/')[1];
if (/(^api$|^unauthenticated$|^internal$|^p$)/.test(path)) { if (/(^api$|^unauthenticated$|^internal$|^p$)/.test(path)) {

View File

@ -17,8 +17,14 @@ const apiProxyRE = /^\/internal\/|^\/external\//;
const newsShortLinksRE = /^\/internal\/n\/|^\/internal\/p\?/; const newsShortLinksRE = /^\/internal\/n\/|^\/internal\/p\?/;
const loopbackAPIPathRE = /^\/internal\/api\//; const loopbackAPIPathRE = /^\/internal\/api\//;
const showCertRe = /^\/internal\/certificate\/showCert\//; 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) { export function isWhiteListedPath(path, whiteListREs = _whiteListREs) {
return whiteListREs.some(re => re.test(path)); return whiteListREs.some(re => re.test(path));

View File

@ -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'
}
};

View File

@ -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
});
});
});
});

View File

@ -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'
};
}

View File

@ -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": { "react-prop-types": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz", "resolved": "https://registry.npmjs.org/react-prop-types/-/react-prop-types-0.4.0.tgz",

View File

@ -56,6 +56,7 @@
"react-identicons": "^1.1.7", "react-identicons": "^1.1.7",
"react-instantsearch-dom": "^6.0.0-beta.0", "react-instantsearch-dom": "^6.0.0-beta.0",
"react-monaco-editor": "^0.31.0", "react-monaco-editor": "^0.31.0",
"react-paypal-button-v2": "^2.6.1",
"react-redux": "^5.0.7", "react-redux": "^5.0.7",
"react-reflex": "^3.0.18", "react-reflex": "^3.0.18",
"react-responsive": "^6.1.1", "react-responsive": "^6.1.1",

View File

@ -137,12 +137,12 @@ class ShowCertification extends Component {
this.setState({ isDonationDisplayed: false, isDonationClosed: true }); this.setState({ isDonationDisplayed: false, isDonationClosed: true });
} }
handleProcessing(duration, amount) { handleProcessing(duration, amount, action = 'stripe form submission') {
this.props.executeGA({ this.props.executeGA({
type: 'event', type: 'event',
data: { data: {
category: 'donation', category: 'donation',
action: 'certificate stripe form submission', action: `certificate ${action}`,
label: duration, label: duration,
value: amount value: amount
} }

View File

@ -212,6 +212,10 @@ li.disabled > a {
} }
} }
.donation-modal {
font-family: 'Lato', sans-serif;
}
.donation-modal .btn-link:focus { .donation-modal .btn-link:focus {
outline-width: 1px; outline-width: 1px;
outline-style: solid; outline-style: solid;

View File

@ -59,12 +59,16 @@ function DonateModal({
executeGA executeGA
}) { }) {
const [closeLabel, setCloseLabel] = React.useState(false); const [closeLabel, setCloseLabel] = React.useState(false);
const handleProcessing = (duration, amount) => { const handleProcessing = (
duration,
amount,
action = 'stripe form submission'
) => {
executeGA({ executeGA({
type: 'event', type: 'event',
data: { data: {
category: 'donation', category: 'donation',
action: 'Modal strip form submission', action: `Modal ${action}`,
label: duration, label: duration,
value: amount value: amount
} }
@ -88,8 +92,8 @@ function DonateModal({
const donationText = ( const donationText = (
<b> <b>
Become a supporter and help us create even more learning resources for Become a $5 / month supporter and help us create even more learning
you. resources for you and your family.
</b> </b>
); );
const blockDonationText = ( const blockDonationText = (

View File

@ -13,8 +13,12 @@ import {
import { stripePublicKey } from '../../../../config/env.json'; import { stripePublicKey } from '../../../../config/env.json';
import { stripeScriptLoader } from '../../utils/scriptLoaders'; import { stripeScriptLoader } from '../../utils/scriptLoaders';
import DonateFormChildViewForHOC from './DonateFormChildViewForHOC'; import DonateFormChildViewForHOC from './DonateFormChildViewForHOC';
import DonateCompletion from './DonateCompletion';
import PaypalButton from './PaypalButton';
import { userSelector } from '../../redux'; import { userSelector } from '../../redux';
import { Spacer } from '../../components/helpers';
import './Donation.css'; import './Donation.css';
const propTypes = { const propTypes = {
@ -33,6 +37,14 @@ const mapStateToProps = createSelector(
}) })
); );
const initialState = {
donationState: {
processing: false,
success: false,
error: ''
}
};
class MinimalDonateForm extends Component { class MinimalDonateForm extends Component {
constructor(...args) { constructor(...args) {
super(...args); super(...args);
@ -42,10 +54,13 @@ class MinimalDonateForm extends Component {
this.state = { this.state = {
...modalDefaultStateConfig, ...modalDefaultStateConfig,
...initialState,
isDonating: this.props.isDonating, isDonating: this.props.isDonating,
stripe: null stripe: null
}; };
this.handleStripeLoad = this.handleStripeLoad.bind(this); this.handleStripeLoad = this.handleStripeLoad.bind(this);
this.onDonationStateChange = this.onDonationStateChange.bind(this);
this.resetDonation = this.resetDonation.bind(this);
} }
componentDidMount() { 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 <DonateCompletion {...props} />;
}
render() { render() {
const { donationAmount, donationDuration, stripe } = this.state; const { donationAmount, donationDuration, stripe } = this.state;
const { handleProcessing, defaultTheme } = this.props; 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 ( return (
<Row> <Row>
<Col lg={8} lgOffset={2} sm={10} smOffset={1} xs={12}>
<PaypalButton
handleProcessing={handleProcessing}
onDonationStateChange={this.onDonationStateChange}
/>
</Col>
<Col sm={10} smOffset={1} xs={12}>
<Spacer />
<b>Or donate with credit card number.</b>
<Spacer />
</Col>
<Col sm={10} smOffset={1} xs={12}> <Col sm={10} smOffset={1} xs={12}>
<StripeProvider stripe={stripe}> <StripeProvider stripe={stripe}>
<Elements> <Elements>
@ -91,7 +148,7 @@ class MinimalDonateForm extends Component {
donationAmount={donationAmount} donationAmount={donationAmount}
donationDuration={donationDuration} donationDuration={donationDuration}
getDonationButtonLabel={() => getDonationButtonLabel={() =>
`Confirm your donation of $5 per month` `Confirm your donation of $5 / month`
} }
handleProcessing={handleProcessing} handleProcessing={handleProcessing}
/> />

View File

@ -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 (
<PayPalButton
createSubscription={(data, actions) => {
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);

View File

@ -42,6 +42,10 @@
padding: 20px 0px; padding: 20px 0px;
} }
.certificate-outer-wrapper .donation-section hr {
border: 1px solid var(--gray-10);
}
.certificate-outer-wrapper .donation-completion .btn { .certificate-outer-wrapper .donation-completion .btn {
background-color: var(--gray-15); background-color: var(--gray-15);
border-color: var(--gray-85); border-color: var(--gray-85);
@ -61,6 +65,11 @@
color: var(--quaternary-color); color: var(--quaternary-color);
} }
.certificate-outer-wrapper .donation-section,
.certificate-outer-wrapper .donation-section p {
font-family: 'Lato', sans-serif;
}
.certification-namespace header { .certification-namespace header {
width: 100%; width: 100%;
height: 140px; height: 140px;

View File

@ -12,6 +12,7 @@ import DonateForm from '../components/Donation/DonateForm';
import DonateText from '../components/Donation/DonateText'; import DonateText from '../components/Donation/DonateText';
import { signInLoadingSelector, userSelector, executeGA } from '../redux'; import { signInLoadingSelector, userSelector, executeGA } from '../redux';
import { stripeScriptLoader } from '../utils/scriptLoaders'; import { stripeScriptLoader } from '../utils/scriptLoaders';
import { PaypalButton } from '../components/Donation/PaypalButton';
const propTypes = { const propTypes = {
executeGA: PropTypes.func, executeGA: PropTypes.func,
@ -118,6 +119,9 @@ export class DonatePage extends Component {
<Spacer /> <Spacer />
</Col> </Col>
</Row> </Row>
<Row>
<PaypalButton />
</Row>
<Row> <Row>
{isDonating ? ( {isDonating ? (
<Col md={6} mdOffset={3}> <Col md={6} mdOffset={3}>

View File

@ -54,6 +54,10 @@ export function postChargeStripe(body) {
return post('/donate/charge-stripe', body); return post('/donate/charge-stripe', body);
} }
export function verifySubscriptionPaypal(body) {
return post('/donate/add-donation', body);
}
export function postCreateHmacHash(body) { export function postCreateHmacHash(body) {
return post(`/donate/create-hmac-hash`, body); return post(`/donate/create-hmac-hash`, body);
} }

View File

@ -1,4 +1,4 @@
/* global describe it expect */ /* global expect */
import { isObject } from 'lodash'; import { isObject } from 'lodash';
import sinon from 'sinon'; import sinon from 'sinon';
import { import {

View File

@ -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 = { module.exports = {
durationsConfig, durationsConfig,
amountsConfig, amountsConfig,
@ -47,5 +71,6 @@ module.exports = {
durationKeysConfig, durationKeysConfig,
donationOneTimeConfig, donationOneTimeConfig,
donationSubscriptionConfig, donationSubscriptionConfig,
modalDefaultStateConfig modalDefaultStateConfig,
paypalConfig
}; };

View File

@ -20,7 +20,8 @@ const {
STRIPE_PUBLIC_KEY: stripePublicKey, STRIPE_PUBLIC_KEY: stripePublicKey,
SERVICEBOT_ID: servicebotId, SERVICEBOT_ID: servicebotId,
ALGOLIA_APP_ID: algoliaAppId, ALGOLIA_APP_ID: algoliaAppId,
ALGOLIA_API_KEY: algoliaAPIKey ALGOLIA_API_KEY: algoliaAPIKey,
PAYPAL_CLIENT_ID: paypalClientId
} = process.env; } = process.env;
const locations = { const locations = {
@ -49,5 +50,9 @@ module.exports = Object.assign(locations, {
algoliaAPIKey: algoliaAPIKey:
!algoliaAPIKey || algoliaAPIKey === 'Algolia api key from dashboard' !algoliaAPIKey || algoliaAPIKey === 'Algolia api key from dashboard'
? null ? null
: algoliaAPIKey : algoliaAPIKey,
paypalClientId:
!paypalClientId || paypalClientId === 'id_from_paypal_dashboard'
? null
: paypalClientId
}); });

View File

@ -33,7 +33,11 @@ const {
STRIPE_PUBLIC_KEY, STRIPE_PUBLIC_KEY,
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
SERVICEBOT_ID, SERVICEBOT_ID,
SERVICEBOT_HMAC_SECRET_KEY
SERVICEBOT_HMAC_SECRET_KEY,
PAYPAL_CLIENT_ID,
PAYPAL_SECRET
} = process.env; } = process.env;
module.exports = { module.exports = {
@ -98,6 +102,11 @@ module.exports = {
secret: STRIPE_SECRET_KEY secret: STRIPE_SECRET_KEY
}, },
paypal: {
client: PAYPAL_CLIENT_ID,
secret: PAYPAL_SECRET
},
servicebot: { servicebot: {
servicebotId: SERVICEBOT_ID, servicebotId: SERVICEBOT_ID,
hmacKey: SERVICEBOT_HMAC_SECRET_KEY hmacKey: SERVICEBOT_HMAC_SECRET_KEY

View File

@ -23,6 +23,8 @@ SERVICEBOT_ID=servicebot_id_from_servicebot_dashboard
SERVICEBOT_HMAC_SECRET_KEY=secret_key_from_servicebot_dashboard SERVICEBOT_HMAC_SECRET_KEY=secret_key_from_servicebot_dashboard
PAYPAL_SUPPORTERS=1703 PAYPAL_SUPPORTERS=1703
PAYPAL_CLIENT_ID=id_from_paypal_dashboard
PAYPAL_SECRET=secret_from_paypal_dashboard
PEER=stuff PEER=stuff
DEBUG=true DEBUG=true