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

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