mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-29 08:00:43 -04:00
* feat: unify post payment actions * feat: handle stripe card error and add donation after auth * feat: add donation saga stripe test * feat: add more coverage to stripe tests * feat: add initial stripe card saga test * feat: finalize initial stripe card saga test * feat: add patreon test saga * feat: test clean up * feat: do not show processing for Patreon * feat: normalize donation settings * feat: turn payment provider/contex to enum * feat: remove donation-settings.js * fix: git ignore generated config * fix: ignore the generate config from everything * fix: remove types.js * fix: update linting to include types.js Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
304 lines
7.8 KiB
JavaScript
304 lines
7.8 KiB
JavaScript
/* eslint-disable camelcase */
|
|
import axios from 'axios';
|
|
import debug from 'debug';
|
|
import isEmail from 'validator/lib/isEmail';
|
|
import { donationSubscriptionConfig } from '../../../../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
|
|
user
|
|
.createDonation(donation)
|
|
.toPromise()
|
|
.catch(err => {
|
|
throw new Error(err);
|
|
});
|
|
};
|
|
|
|
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 },
|
|
user: { name, id: userId, email },
|
|
user
|
|
} = req;
|
|
|
|
if (!paymentMethodId || !amount || !duration || !userId || !email) {
|
|
throw {
|
|
message: 'Request is not valid',
|
|
type: 'InvalidRequest'
|
|
};
|
|
}
|
|
|
|
/*
|
|
* if user is already donating and the donation isn't one time only,
|
|
* throw error
|
|
*/
|
|
if (user.isDonating && duration !== 'one-time') {
|
|
throw {
|
|
message: `User already has active recurring donation(s).`,
|
|
type: 'AlreadyDonatingError'
|
|
};
|
|
}
|
|
|
|
let customerId;
|
|
try {
|
|
const customer = await stripe.customers.create({
|
|
email,
|
|
payment_method: paymentMethodId,
|
|
invoice_settings: { default_payment_method: paymentMethodId },
|
|
...(name && { name })
|
|
});
|
|
customerId = customer?.id;
|
|
} catch {
|
|
throw {
|
|
type: 'customerCreationFailed',
|
|
message: 'Failed to create stripe customer'
|
|
};
|
|
}
|
|
log(`Stripe customer with id ${customerId} created`);
|
|
|
|
let subscriptionId;
|
|
try {
|
|
const {
|
|
id: subscription_id,
|
|
latest_invoice: {
|
|
payment_intent: { client_secret, status: intent_status }
|
|
}
|
|
} = await stripe.subscriptions.create({
|
|
// create Stripe subscription
|
|
customer: customerId,
|
|
payment_behavior: 'allow_incomplete',
|
|
items: [
|
|
{
|
|
plan: `${donationSubscriptionConfig.duration[
|
|
duration
|
|
].toLowerCase()}-donation-${amount}`
|
|
}
|
|
],
|
|
expand: ['latest_invoice.payment_intent']
|
|
});
|
|
|
|
if (intent_status === 'requires_source_action')
|
|
throw {
|
|
type: 'UserActionRequired',
|
|
message: 'Payment requires user action',
|
|
client_secret
|
|
};
|
|
else if (intent_status === 'requires_source')
|
|
throw {
|
|
type: 'PaymentMethodRequired',
|
|
message: 'Card has been declined'
|
|
};
|
|
subscriptionId = subscription_id;
|
|
} catch (err) {
|
|
if (
|
|
err.type === 'UserActionRequired' ||
|
|
err.type === 'PaymentMethodRequired'
|
|
)
|
|
throw err;
|
|
else
|
|
throw {
|
|
type: 'SubscriptionCreationFailed',
|
|
message: 'Failed to create stripe subscription'
|
|
};
|
|
}
|
|
log(`Stripe subscription with id ${subscriptionId} created`);
|
|
|
|
// save Donation
|
|
let donation = {
|
|
email,
|
|
amount,
|
|
duration,
|
|
provider: 'stripe',
|
|
subscriptionId,
|
|
customerId,
|
|
startDate: new Date().toISOString()
|
|
};
|
|
await createAsyncUserDonation(user, donation);
|
|
return res.status(200).json({ isDonating: true });
|
|
}
|