mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-06-05 21:04:28 +08:00
feat: remove paypal webhook listener (#54395)
This commit is contained in:
parent
5cbe0b709e
commit
20b6b83e99
1
.github/workflows/e2e-third-party.yml
vendored
1
.github/workflows/e2e-third-party.yml
vendored
@ -57,7 +57,6 @@ jobs:
|
||||
echo 'STRIPE_PUBLIC_KEY=${{ secrets.STRIPE_PUBLIC_KEY }}' >> .env
|
||||
echo 'STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }}' >> .env
|
||||
echo 'PAYPAL_CLIENT_ID=${{ secrets.PAYPAL_CLIENT_ID }}' >> .env
|
||||
echo 'PAYPAL_SECRET=${{ secrets.PAYPAL_SECRET }}' >> .env
|
||||
|
||||
- name: Install and Build
|
||||
run: |
|
||||
|
||||
@ -30,13 +30,7 @@ const {
|
||||
SENTRY_DSN,
|
||||
|
||||
STRIPE_PUBLIC_KEY,
|
||||
STRIPE_SECRET_KEY,
|
||||
|
||||
PAYPAL_CLIENT_ID,
|
||||
PAYPAL_SECRET,
|
||||
PAYPAL_VERIFY_WEBHOOK_URL,
|
||||
PAYPAL_API_TOKEN_URL,
|
||||
PAYPAL_WEBHOOK_ID
|
||||
STRIPE_SECRET_KEY
|
||||
} = process.env;
|
||||
|
||||
module.exports = {
|
||||
@ -98,13 +92,5 @@ module.exports = {
|
||||
stripe: {
|
||||
public: STRIPE_PUBLIC_KEY,
|
||||
secret: STRIPE_SECRET_KEY
|
||||
},
|
||||
|
||||
paypal: {
|
||||
client: PAYPAL_CLIENT_ID,
|
||||
secret: PAYPAL_SECRET,
|
||||
verifyWebhookURL: PAYPAL_VERIFY_WEBHOOK_URL,
|
||||
tokenUrl: PAYPAL_API_TOKEN_URL,
|
||||
webhookId: PAYPAL_WEBHOOK_ID
|
||||
}
|
||||
};
|
||||
|
||||
@ -32,7 +32,6 @@
|
||||
"@sentry/node": "7.37.1",
|
||||
"@sentry/tracing": "7.37.1",
|
||||
"accepts": "1.3.8",
|
||||
"axios": "0.23.0",
|
||||
"body-parser": "1.20.0",
|
||||
"compression": "1.7.4",
|
||||
"connect-mongo": "3.2.0",
|
||||
@ -88,7 +87,6 @@
|
||||
"@babel/preset-env": "7.18.0",
|
||||
"@babel/register": "7.17.7",
|
||||
"loopback-component-explorer": "6.4.0",
|
||||
"nodemon": "2.0.16",
|
||||
"smee-client": "1.2.3"
|
||||
"nodemon": "2.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,30 +3,8 @@ require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
const createDebugger = require('debug');
|
||||
const nodemon = require('nodemon');
|
||||
const SmeeClient = require('smee-client');
|
||||
|
||||
const log = createDebugger('fcc:start:development');
|
||||
|
||||
if (process.env.WEBHOOK_PROXY_URL) {
|
||||
const paypalPayloadHandler = new SmeeClient({
|
||||
source: process.env.WEBHOOK_PROXY_URL,
|
||||
target: process.env.API_LOCATION + '/hooks/update-paypal',
|
||||
logger: { info: log, error: log }
|
||||
});
|
||||
|
||||
const paypalevents = paypalPayloadHandler.start();
|
||||
|
||||
process.on('exit', () => {
|
||||
log('Stopping webhook proxy client.');
|
||||
paypalevents.close(() => {
|
||||
log('Webhook proxy client is stopped.');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
log('Webhook client is not configured.');
|
||||
log('This can be ignored when not working with webhooks locally.');
|
||||
}
|
||||
|
||||
nodemon({
|
||||
ext: 'js json',
|
||||
// --silent squashes an ELIFECYCLE error when the server exits
|
||||
|
||||
@ -4,10 +4,6 @@ import Stripe from 'stripe';
|
||||
import { donationSubscriptionConfig } from '../../../../shared/config/donation-settings';
|
||||
import keys from '../../../config/secrets';
|
||||
import {
|
||||
getAsyncPaypalToken,
|
||||
verifyWebHook,
|
||||
updateUser,
|
||||
verifyWebHookType,
|
||||
createStripeCardDonation,
|
||||
handleStripeCardUpdateSession
|
||||
} from '../utils/donation';
|
||||
@ -195,37 +191,15 @@ export default function donateBoot(app, done) {
|
||||
}
|
||||
}
|
||||
|
||||
function updatePaypal(req, res) {
|
||||
const { headers, body } = req;
|
||||
return Promise.resolve(req)
|
||||
.then(verifyWebHookType)
|
||||
.then(getAsyncPaypalToken)
|
||||
.then(token => verifyWebHook(headers, body, token, keys.paypal.webhookId))
|
||||
.then(hookBody => updateUser(hookBody, app))
|
||||
.catch(err => {
|
||||
// Todo: This probably need to be thrown and caught in error handler
|
||||
log(err.message);
|
||||
})
|
||||
.finally(() => res.status(200).json({ message: 'received paypal hook' }));
|
||||
}
|
||||
|
||||
const stripeKey = keys.stripe.public;
|
||||
const secKey = keys.stripe.secret;
|
||||
const paypalKey = keys.paypal.client;
|
||||
const paypalSec = keys.paypal.secret;
|
||||
|
||||
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 stripeInvalid = stripeSecretInvalid || stripPublicInvalid;
|
||||
const paypalInvalid = paypalPublicInvalid || paypalSecretInvalid;
|
||||
|
||||
if (stripeInvalid || paypalInvalid) {
|
||||
if (stripeInvalid) {
|
||||
if (process.env.FREECODECAMP_NODE_ENV === 'production') {
|
||||
throw new Error('Donation API keys are required to boot the server!');
|
||||
}
|
||||
@ -236,7 +210,6 @@ export default function donateBoot(app, done) {
|
||||
api.post('/charge-stripe-card', handleStripeCardDonation);
|
||||
api.put('/update-stripe-card', handleStripeCardUpdate);
|
||||
api.post('/add-donation', addDonation);
|
||||
hooks.post('/update-paypal', updatePaypal);
|
||||
donateRouter.use('/donate', api);
|
||||
donateRouter.use('/hooks', hooks);
|
||||
app.use(donateRouter);
|
||||
|
||||
@ -14,9 +14,7 @@ export default function getCsurf() {
|
||||
const { path } = req;
|
||||
if (
|
||||
// eslint-disable-next-line max-len
|
||||
/^\/hooks\/update-paypal$|^\/donate\/charge-stripe$|^\/coderoad-challenge-completed$/.test(
|
||||
path
|
||||
)
|
||||
/^\/donate\/charge-stripe$|^\/coderoad-challenge-completed$/.test(path)
|
||||
) {
|
||||
next();
|
||||
} else {
|
||||
|
||||
@ -22,7 +22,6 @@ const signinRE = /^\/signin/;
|
||||
const statusRE = /^\/status\/ping$/;
|
||||
const unsubscribedRE = /^\/unsubscribed\//;
|
||||
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
|
||||
const updateHooksRE = /^\/hooks\/update-paypal$/;
|
||||
// note: this would be replaced by webhooks later
|
||||
const donateRE = /^\/donate\/charge-stripe$/;
|
||||
const submitCoderoadChallengeRE = /^\/coderoad-challenge-completed$/;
|
||||
@ -40,7 +39,6 @@ const _pathsAllowedREs = [
|
||||
statusRE,
|
||||
unsubscribedRE,
|
||||
unsubscribeRE,
|
||||
updateHooksRE,
|
||||
donateRE,
|
||||
submitCoderoadChallengeRE,
|
||||
mobileLoginRE
|
||||
|
||||
@ -48,7 +48,6 @@ describe('request-authorization', () => {
|
||||
const statusRE = /^\/status\/ping$/;
|
||||
const unsubscribedRE = /^\/unsubscribed\//;
|
||||
const unsubscribeRE = /^\/u\/|^\/unsubscribe\/|^\/ue\//;
|
||||
const updateHooksRE = /^\/hooks\/update-paypal$/;
|
||||
|
||||
const allowedPathsList = [
|
||||
authRE,
|
||||
@ -61,8 +60,7 @@ describe('request-authorization', () => {
|
||||
signinRE,
|
||||
statusRE,
|
||||
unsubscribedRE,
|
||||
unsubscribeRE,
|
||||
updateHooksRE
|
||||
unsubscribeRE
|
||||
];
|
||||
|
||||
it('returns a boolean', () => {
|
||||
@ -79,10 +77,8 @@ describe('request-authorization', () => {
|
||||
'/ue/WmjInLerysPrcon6fMb/',
|
||||
allowedPathsList
|
||||
);
|
||||
const resultC = isAllowedPath('/hooks/update-paypal', allowedPathsList);
|
||||
expect(resultA).toBe(true);
|
||||
expect(resultB).toBe(true);
|
||||
expect(resultC).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-white-listed path', () => {
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
/* 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'
|
||||
}
|
||||
};
|
||||
@ -1,91 +1,15 @@
|
||||
/* eslint-disable camelcase */
|
||||
import axios from 'axios';
|
||||
import debug from 'debug';
|
||||
import isEmail from 'validator/lib/isEmail';
|
||||
import { donationSubscriptionConfig } from '../../../../shared/config/donation-settings';
|
||||
import keys from '../../../config/secrets';
|
||||
|
||||
const log = debug('fcc:boot:donate');
|
||||
|
||||
const paypalVerifyWebhookURL =
|
||||
keys.paypal.verifyWebhookURL ||
|
||||
`https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature`;
|
||||
const paypalTokenURL =
|
||||
keys.paypal.tokenUrl || `https://api.sandbox.paypal.com/v1/oauth2/token`;
|
||||
|
||||
export async function getAsyncPaypalToken() {
|
||||
const res = await axios.post(paypalTokenURL, 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(paypalVerifyWebhookURL, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.verification_status === 'SUCCESS') {
|
||||
return body;
|
||||
} else {
|
||||
throw {
|
||||
// if verification fails, throw token verification error
|
||||
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}`);
|
||||
// log user donation
|
||||
@ -97,108 +21,6 @@ export const createAsyncUserDonation = (user, donation) => {
|
||||
});
|
||||
};
|
||||
|
||||
export function createDonationObj(body) {
|
||||
// creates donation object
|
||||
const {
|
||||
resource: {
|
||||
id,
|
||||
status_update_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(status_update_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;
|
||||
if (!email || !isEmail(email)) {
|
||||
throw {
|
||||
message: 'Paypal webhook email is not valid',
|
||||
type: 'InvalidPaypalWebhookEmail'
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
message:
|
||||
err.message || 'findOne Donation records with email failed',
|
||||
type: err.name || 'FailedFindingOneDonationEmail'
|
||||
};
|
||||
});
|
||||
}
|
||||
return createAsyncUserDonation(user, donation);
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelDonation(body, app) {
|
||||
const {
|
||||
resource: { id, status_update_time = new Date(Date.now()).toISOString() }
|
||||
} = body;
|
||||
const { Donation } = app.models;
|
||||
Donation.findOne({ where: { subscriptionId: id } }, (err, donation) => {
|
||||
if (err)
|
||||
throw {
|
||||
message:
|
||||
err.message || 'findOne Donation records with subscriptionId failed',
|
||||
type: err.name || 'FailedFindingOneSubscriptionId'
|
||||
};
|
||||
if (!donation)
|
||||
throw {
|
||||
message: 'Donation record with provided subscription id is not found',
|
||||
type: 'SubscriptionIdNotFound'
|
||||
};
|
||||
log(`Updating donation record: ${donation.subscriptionId}`);
|
||||
donation.updateAttributes({
|
||||
endDate: new Date(status_update_time).toISOString()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(body, app) {
|
||||
const { event_type } = body;
|
||||
if (event_type === 'BILLING.SUBSCRIPTION.ACTIVATED') {
|
||||
// update user status based on new billing subscription events
|
||||
createDonation(body, app);
|
||||
} else if (event_type === 'BILLING.SUBSCRIPTION.CANCELLED') {
|
||||
cancelDonation(body, app);
|
||||
} else
|
||||
throw {
|
||||
message: 'Webhook type is not supported',
|
||||
type: 'UnsupportedWebhookType'
|
||||
};
|
||||
}
|
||||
|
||||
export async function createStripeCardDonation(req, res, stripe) {
|
||||
const {
|
||||
body: { paymentMethodId, amount, duration },
|
||||
|
||||
@ -1,26 +1,8 @@
|
||||
/* eslint-disable camelcase */
|
||||
import axios from 'axios';
|
||||
import stripe from 'stripe';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import keys from '../../../config/secrets';
|
||||
import {
|
||||
mockApp,
|
||||
createDonationMockFn,
|
||||
createUserMockFn,
|
||||
updateDonationAttr,
|
||||
updateUserAttr
|
||||
} from '../boot_tests/fixtures';
|
||||
import { mockActivationHook, mockCancellationHook } from './__mocks__/donation';
|
||||
import {
|
||||
getAsyncPaypalToken,
|
||||
verifyWebHook,
|
||||
updateUser,
|
||||
capitalizeKeys,
|
||||
createDonationObj,
|
||||
handleStripeCardUpdateSession
|
||||
} from './donation';
|
||||
import { handleStripeCardUpdateSession } from './donation';
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('stripe', () => ({
|
||||
checkout: {
|
||||
sessions: {
|
||||
@ -29,143 +11,7 @@ jest.mock('stripe', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
const verificationUrl = `https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature`;
|
||||
const tokenUrl = `https://api.sandbox.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 successVerificationResponse = {
|
||||
data: {
|
||||
verification_status: 'SUCCESS'
|
||||
}
|
||||
};
|
||||
const failedVerificationResponse = {
|
||||
data: {
|
||||
verification_status: 'FAILED'
|
||||
}
|
||||
};
|
||||
|
||||
it('calls paypal for Webhook verification', async () => {
|
||||
axios.post.mockImplementationOnce(() =>
|
||||
Promise.resolve(successVerificationResponse)
|
||||
);
|
||||
|
||||
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(failedVerificationResponse)
|
||||
);
|
||||
|
||||
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).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleStripeCardUpdateSession', () => {
|
||||
const mockUserId = ObjectId('507f1f77bcf86cd799439011');
|
||||
const mockDonation = {
|
||||
|
||||
@ -310,9 +310,6 @@ importers:
|
||||
accepts:
|
||||
specifier: 1.3.8
|
||||
version: 1.3.8
|
||||
axios:
|
||||
specifier: 0.23.0
|
||||
version: 0.23.0(debug@2.2.0)
|
||||
body-parser:
|
||||
specifier: 1.20.0
|
||||
version: 1.20.0
|
||||
@ -476,9 +473,6 @@ importers:
|
||||
nodemon:
|
||||
specifier: 2.0.16
|
||||
version: 2.0.16
|
||||
smee-client:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
|
||||
client:
|
||||
dependencies:
|
||||
@ -2196,7 +2190,7 @@ packages:
|
||||
'@babel/traverse': 7.23.0
|
||||
'@babel/types': 7.23.0
|
||||
convert-source-map: 1.9.0
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@ -2430,7 +2424,7 @@ packages:
|
||||
'@babel/core': 7.18.0
|
||||
'@babel/helper-compilation-targets': 7.22.15
|
||||
'@babel/helper-plugin-utils': 7.22.5
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
lodash.debounce: 4.0.8
|
||||
resolve: 1.22.8
|
||||
semver: 6.3.1
|
||||
@ -5675,7 +5669,7 @@ packages:
|
||||
'@babel/helper-split-export-declaration': 7.22.6
|
||||
'@babel/parser': 7.23.3
|
||||
'@babel/types': 7.23.3
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
globals: 11.12.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@ -5692,7 +5686,7 @@ packages:
|
||||
'@babel/helper-split-export-declaration': 7.22.6
|
||||
'@babel/parser': 7.23.9
|
||||
'@babel/types': 7.23.9
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
globals: 11.12.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@ -9424,7 +9418,7 @@ packages:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -9984,14 +9978,6 @@ packages:
|
||||
- debug
|
||||
dev: false
|
||||
|
||||
/axios@0.23.0(debug@2.2.0):
|
||||
resolution: {integrity: sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==}
|
||||
dependencies:
|
||||
follow-redirects: 1.15.3(debug@2.2.0)
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
dev: false
|
||||
|
||||
/axios@1.6.7(debug@4.3.4):
|
||||
resolution: {integrity: sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==}
|
||||
dependencies:
|
||||
@ -11886,6 +11872,7 @@ packages:
|
||||
/chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
braces: 3.0.2
|
||||
@ -13015,6 +13002,17 @@ packages:
|
||||
ms: 2.1.2
|
||||
dev: true
|
||||
|
||||
/debug@4.3.4:
|
||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
dependencies:
|
||||
ms: 2.1.2
|
||||
|
||||
/debug@4.3.4(supports-color@8.1.1):
|
||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@ -14703,11 +14701,6 @@ packages:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
/eventsource@1.1.2:
|
||||
resolution: {integrity: sha512-xAH3zWhgO2/3KIniEKYPr8plNSzlGINOUqYj0m0u7AB81iRw8b/3E73W6AuU+6klLbaSFmZnaETQ2lXPfAydrA==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
dev: true
|
||||
|
||||
/evp_bytestokey@1.0.3:
|
||||
resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==}
|
||||
dependencies:
|
||||
@ -17100,7 +17093,7 @@ packages:
|
||||
engines: {node: '>= 6'}
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@ -19277,7 +19270,7 @@ packages:
|
||||
dependencies:
|
||||
async: 3.2.4
|
||||
bluebird: 3.7.2
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
msgpack5: 4.5.1
|
||||
strong-globalize: 5.1.0
|
||||
uuid: 7.0.3
|
||||
@ -24347,19 +24340,6 @@ packages:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
/smee-client@1.2.3:
|
||||
resolution: {integrity: sha512-uDrU8u9/Ln7aRXyzGHgVaNUS8onHZZeSwQjCdkMoSL7U85xI+l+Y2NgjibkMJAyXkW7IAbb8rw9RMHIjS6lAwA==}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
eventsource: 1.1.2
|
||||
morgan: 1.10.0
|
||||
superagent: 7.1.5
|
||||
validator: 13.11.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/smtp-connection@2.12.0:
|
||||
resolution: {integrity: sha512-UP5jK4s5SGcUcqPN4U9ingqKt9mXYSKa52YhqxPuMecAnUOsVJpOmtgGaOm1urUBJZlzDt1M9WhZZkgbhxQlvg==}
|
||||
dependencies:
|
||||
@ -24968,7 +24948,7 @@ packages:
|
||||
dependencies:
|
||||
'@types/express': 4.17.18
|
||||
accepts: 1.3.8
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
ejs: 3.1.9
|
||||
fast-safe-stringify: 2.1.1
|
||||
http-status: 1.7.0
|
||||
@ -24983,7 +24963,7 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dependencies:
|
||||
accept-language: 3.0.18
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
globalize: 1.7.0
|
||||
lodash: 4.17.21
|
||||
md5: 2.3.0
|
||||
@ -24998,7 +24978,7 @@ packages:
|
||||
engines: {node: '>=8.9'}
|
||||
dependencies:
|
||||
accept-language: 3.0.18
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
globalize: 1.7.0
|
||||
lodash: 4.17.21
|
||||
md5: 2.3.0
|
||||
@ -25014,7 +24994,7 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
dependencies:
|
||||
accept-language: 3.0.18
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
globalize: 1.7.0
|
||||
lodash: 4.17.21
|
||||
md5: 2.3.0
|
||||
@ -25031,7 +25011,7 @@ packages:
|
||||
dependencies:
|
||||
async: 3.2.4
|
||||
body-parser: 1.20.0
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
debug: 4.3.4
|
||||
depd: 2.0.0
|
||||
escape-string-regexp: 2.0.0
|
||||
eventemitter2: 5.0.1
|
||||
@ -25119,25 +25099,6 @@ packages:
|
||||
/sudo-prompt@8.2.5:
|
||||
resolution: {integrity: sha512-rlBo3HU/1zAJUrkY6jNxDOC9eVYliG6nS4JA8u8KAshITd07tafMc/Br7xQwCSseXwJ2iCcHCE8SNWX3q8Z+kw==}
|
||||
|
||||
/superagent@7.1.5:
|
||||
resolution: {integrity: sha512-HQYyGuDRFGmZ6GNC4hq2f37KnsY9Lr0/R1marNZTgMweVDQLTLJJ6DGQ9Tj/xVVs5HEnop9EMmTbywb5P30aqw==}
|
||||
engines: {node: '>=6.4.0 <13 || >=14'}
|
||||
dependencies:
|
||||
component-emitter: 1.3.0
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.0
|
||||
formidable: 2.1.2
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.11.2
|
||||
readable-stream: 3.6.2
|
||||
semver: 7.6.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/superagent@8.1.2:
|
||||
resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==}
|
||||
engines: {node: '>=6.4.0 <13 || >=14'}
|
||||
@ -26408,6 +26369,7 @@ packages:
|
||||
/validator@13.11.0:
|
||||
resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==}
|
||||
engines: {node: '>= 0.10'}
|
||||
dev: false
|
||||
|
||||
/validator@13.7.0:
|
||||
resolution: {integrity: sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==}
|
||||
|
||||
@ -27,13 +27,6 @@ STRIPE_SECRET_KEY=sk_from_stripe_dashboard
|
||||
|
||||
# PayPal
|
||||
PAYPAL_CLIENT_ID=id_from_paypal_dashboard
|
||||
PAYPAL_SECRET=secret_from_paypal_dashboard
|
||||
PAYPAL_VERIFY_WEBHOOK_URL=https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature
|
||||
PAYPAL_API_TOKEN_URL=https://api.sandbox.paypal.com/v1/oauth2/token
|
||||
PAYPAL_WEBHOOK_ID=webhook_id_from_paypal_dashboard
|
||||
|
||||
# Webhook proxy url from smee.io for PayPal
|
||||
WEBHOOK_PROXY_URL=
|
||||
|
||||
# Patreon
|
||||
PATREON_CLIENT_ID=id_from_patreon_dashboard
|
||||
|
||||
Loading…
Reference in New Issue
Block a user