feat(donate): PayPal integration
This commit is contained in:
committed by
Mrugesh Mohapatra
parent
e3db423abf
commit
6c6eadfbe4
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -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)) {
|
||||
|
@ -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));
|
||||
|
214
api-server/server/utils/__mocks__/donation.js
Normal file
214
api-server/server/utils/__mocks__/donation.js
Normal 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'
|
||||
}
|
||||
};
|
169
api-server/server/utils/donation-uttils.test.js
Normal file
169
api-server/server/utils/donation-uttils.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
182
api-server/server/utils/donation.js
Normal file
182
api-server/server/utils/donation.js
Normal 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'
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user