mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 14:43:08 +03:00
aa228e9eb9
refs https://github.com/TryGhost/Ghost/issues/12270 Previously we would create the member, and then update their name from stripe data, this mean that webhooks would be sent _without_ a name, despite us possibly having the information to provide one. Here we've updated the creation of members to include the name attached to the default billing method, this will ensure that webhooks are sent with all availiable information.
505 lines
17 KiB
JavaScript
505 lines
17 KiB
JavaScript
const _ = require('lodash');
|
|
const {Router} = require('express');
|
|
const body = require('body-parser');
|
|
const MagicLink = require('@tryghost/magic-link');
|
|
const StripePaymentProcessor = require('./lib/stripe');
|
|
const Tokens = require('./lib/tokens');
|
|
const Users = require('./lib/users');
|
|
const Metadata = require('./lib/metadata');
|
|
const common = require('./lib/common');
|
|
const {getGeolocationFromIP} = require('./lib/geolocation');
|
|
|
|
module.exports = function MembersApi({
|
|
tokenConfig: {
|
|
issuer,
|
|
privateKey,
|
|
publicKey
|
|
},
|
|
auth: {
|
|
allowSelfSignup = true,
|
|
getSigninURL,
|
|
tokenProvider
|
|
},
|
|
paymentConfig,
|
|
mail: {
|
|
transporter,
|
|
getText,
|
|
getHTML,
|
|
getSubject
|
|
},
|
|
models: {
|
|
StripeWebhook,
|
|
StripeCustomer,
|
|
StripeCustomerSubscription,
|
|
Member
|
|
},
|
|
logger
|
|
}) {
|
|
if (logger) {
|
|
common.logging.setLogger(logger);
|
|
}
|
|
|
|
const {encodeIdentityToken, decodeToken} = Tokens({privateKey, publicKey, issuer});
|
|
const metadata = Metadata({
|
|
Member,
|
|
StripeWebhook,
|
|
StripeCustomer,
|
|
StripeCustomerSubscription
|
|
});
|
|
|
|
async function hasActiveStripeSubscriptions() {
|
|
const firstActiveSubscription = await StripeCustomerSubscription.findOne({
|
|
status: 'active'
|
|
});
|
|
|
|
if (firstActiveSubscription) {
|
|
return true;
|
|
}
|
|
|
|
const firstTrialingSubscription = await StripeCustomerSubscription.findOne({
|
|
status: 'trialing'
|
|
});
|
|
|
|
if (firstTrialingSubscription) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const stripeStorage = {
|
|
async get(member) {
|
|
return metadata.getMetadata('stripe', member);
|
|
},
|
|
async set(data) {
|
|
return metadata.setMetadata('stripe', data);
|
|
}
|
|
};
|
|
const stripe = paymentConfig.stripe ? new StripePaymentProcessor(paymentConfig.stripe, stripeStorage, common.logging) : null;
|
|
|
|
async function ensureStripe(_req, res, next) {
|
|
if (!stripe) {
|
|
res.writeHead(400);
|
|
return res.end('Stripe not configured');
|
|
}
|
|
try {
|
|
await stripe.ready();
|
|
next();
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end('There was an error configuring stripe');
|
|
}
|
|
}
|
|
|
|
const magicLinkService = new MagicLink({
|
|
transporter,
|
|
tokenProvider,
|
|
getSigninURL,
|
|
getText,
|
|
getHTML,
|
|
getSubject
|
|
});
|
|
|
|
const users = Users({
|
|
stripe,
|
|
Member
|
|
});
|
|
|
|
async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}}) {
|
|
let type = requestedType;
|
|
if (!options.forceEmailType) {
|
|
const member = await users.get({email});
|
|
if (member) {
|
|
type = 'signin';
|
|
} else if (type !== 'subscribe') {
|
|
type = 'signup';
|
|
}
|
|
}
|
|
return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email}, tokenData)});
|
|
}
|
|
|
|
function getMagicLink(email) {
|
|
return magicLinkService.getMagicLink({tokenData: {email}, type: 'signin'});
|
|
}
|
|
|
|
async function getMemberDataFromMagicLinkToken(token) {
|
|
const {email, labels = [], name = '', oldEmail} = await magicLinkService.getDataFromToken(token);
|
|
if (!email) {
|
|
return null;
|
|
}
|
|
|
|
const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);
|
|
|
|
if (member) {
|
|
if (oldEmail) {
|
|
// user exists but wants to change their email address
|
|
if (oldEmail) {
|
|
member.email = email;
|
|
}
|
|
await users.update(member, {id: member.id});
|
|
return getMemberIdentityData(email);
|
|
}
|
|
return member;
|
|
}
|
|
|
|
await users.create({name, email, labels});
|
|
return getMemberIdentityData(email);
|
|
}
|
|
|
|
async function getMemberIdentityData(email) {
|
|
const model = await users.get({email}, {withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer']});
|
|
if (!model) {
|
|
return null;
|
|
}
|
|
return model.toJSON();
|
|
}
|
|
|
|
async function getMemberIdentityToken(email) {
|
|
const member = await getMemberIdentityData(email);
|
|
if (!member) {
|
|
return null;
|
|
}
|
|
return encodeIdentityToken({sub: member.email});
|
|
}
|
|
|
|
async function setMemberGeolocationFromIp(email, ip) {
|
|
if (!email || !ip) {
|
|
throw new common.errors.IncorrectUsageError({
|
|
message: 'setMemberGeolocationFromIp() expects email and ip arguments to be present'
|
|
});
|
|
}
|
|
|
|
const member = await users.get({email}, {
|
|
withRelated: ['labels']
|
|
});
|
|
|
|
if (!member) {
|
|
throw new common.errors.NotFoundError({
|
|
message: `Member with email address ${email} does not exist`
|
|
});
|
|
}
|
|
|
|
// max request time is 500ms so shouldn't slow requests down too much
|
|
let geolocation = JSON.stringify(await getGeolocationFromIP(ip));
|
|
if (geolocation) {
|
|
member.geolocation = geolocation;
|
|
await users.update(member, {id: member.id});
|
|
}
|
|
|
|
return getMemberIdentityData(email);
|
|
}
|
|
|
|
const middleware = {
|
|
sendMagicLink: Router(),
|
|
createCheckoutSession: Router(),
|
|
createCheckoutSetupSession: Router(),
|
|
handleStripeWebhook: Router(),
|
|
updateSubscription: Router({mergeParams: true})
|
|
};
|
|
|
|
middleware.sendMagicLink.use(body.json(), async function (req, res) {
|
|
const {email, emailType, oldEmail} = req.body;
|
|
|
|
if (!email) {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
try {
|
|
if (oldEmail) {
|
|
const existingMember = await users.get({email});
|
|
if (existingMember) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'This email is already associated with a member'
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!allowSelfSignup) {
|
|
const member = oldEmail ? await users.get({oldEmail}) : await users.get({email});
|
|
if (member) {
|
|
const tokenData = _.pick(req.body, ['oldEmail']);
|
|
const forceEmailType = oldEmail ? true : false;
|
|
await sendEmailWithMagicLink({email, tokenData, requestedType: emailType, options: {forceEmailType}});
|
|
}
|
|
} else {
|
|
const tokenData = _.pick(req.body, ['labels', 'name', 'oldEmail']);
|
|
await sendEmailWithMagicLink({email, tokenData, requestedType: emailType});
|
|
}
|
|
res.writeHead(201);
|
|
return res.end('Created.');
|
|
} catch (err) {
|
|
const statusCode = (err && err.statusCode) || 500;
|
|
common.logging.error(err);
|
|
res.writeHead(statusCode);
|
|
return res.end('Internal Server Error.');
|
|
}
|
|
});
|
|
|
|
middleware.createCheckoutSession.use(ensureStripe, body.json(), async function (req, res) {
|
|
const plan = req.body.plan;
|
|
const identity = req.body.identity;
|
|
|
|
if (!plan) {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
// NOTE: never allow "Complimenatry" plan to be subscribed to from the client
|
|
if (plan.toLowerCase() === 'complimentary') {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
let email;
|
|
try {
|
|
if (!identity) {
|
|
email = null;
|
|
} else {
|
|
const claims = await decodeToken(identity);
|
|
email = claims.sub;
|
|
}
|
|
} catch (err) {
|
|
res.writeHead(401);
|
|
return res.end('Unauthorized');
|
|
}
|
|
|
|
const member = email ? await users.get({email}, {withRelated: ['stripeSubscriptions']}) : null;
|
|
|
|
// Do not allow members already with a subscription to initiate a new checkout session
|
|
if (member && member.related('stripeSubscriptions').length > 0) {
|
|
res.writeHead(403);
|
|
return res.end('No permission');
|
|
}
|
|
|
|
try {
|
|
const sessionInfo = await stripe.createCheckoutSession(member, plan, {
|
|
successUrl: req.body.successUrl,
|
|
cancelUrl: req.body.cancelUrl,
|
|
customerEmail: req.body.customerEmail,
|
|
metadata: req.body.metadata
|
|
});
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
|
|
res.end(JSON.stringify(sessionInfo));
|
|
} catch (e) {
|
|
const error = e.message || 'Unable to initiate checkout session';
|
|
res.writeHead(400);
|
|
return res.end(error);
|
|
}
|
|
});
|
|
|
|
middleware.createCheckoutSetupSession.use(ensureStripe, body.json(), async function (req, res) {
|
|
const identity = req.body.identity;
|
|
|
|
let email;
|
|
try {
|
|
if (!identity) {
|
|
email = null;
|
|
} else {
|
|
const claims = await decodeToken(identity);
|
|
email = claims.sub;
|
|
}
|
|
} catch (err) {
|
|
res.writeHead(401);
|
|
return res.end('Unauthorized');
|
|
}
|
|
|
|
const member = email ? await users.get({email}) : null;
|
|
|
|
if (!member) {
|
|
res.writeHead(403);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
const sessionInfo = await stripe.createCheckoutSetupSession(member, {
|
|
successUrl: req.body.successUrl,
|
|
cancelUrl: req.body.cancelUrl
|
|
});
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
|
|
res.end(JSON.stringify(sessionInfo));
|
|
});
|
|
|
|
middleware.handleStripeWebhook.use(ensureStripe, body.raw({type: 'application/json'}), async function (req, res) {
|
|
let event;
|
|
try {
|
|
event = await stripe.parseWebhook(req.body, req.headers['stripe-signature']);
|
|
} catch (err) {
|
|
common.logging.error(err);
|
|
res.writeHead(401);
|
|
return res.end();
|
|
}
|
|
common.logging.info(`Handling webhook ${event.type}`);
|
|
try {
|
|
if (event.type === 'customer.subscription.deleted') {
|
|
await stripe.handleCustomerSubscriptionDeletedWebhook(event.data.object);
|
|
}
|
|
|
|
if (event.type === 'customer.subscription.updated') {
|
|
await stripe.handleCustomerSubscriptionUpdatedWebhook(event.data.object);
|
|
}
|
|
|
|
if (event.type === 'customer.subscription.created') {
|
|
await stripe.handleCustomerSubscriptionCreatedWebhook(event.data.object);
|
|
}
|
|
|
|
if (event.type === 'invoice.payment_succeeded') {
|
|
await stripe.handleInvoicePaymentSucceededWebhook(event.data.object);
|
|
}
|
|
|
|
if (event.type === 'invoice.payment_failed') {
|
|
await stripe.handleInvoicePaymentFailedWebhook(event.data.object);
|
|
}
|
|
|
|
if (event.type === 'checkout.session.completed') {
|
|
if (event.data.object.mode === 'setup') {
|
|
common.logging.info('Handling "setup" mode Checkout Session');
|
|
const setupIntent = await stripe.getSetupIntent(event.data.object.setup_intent);
|
|
const customer = await stripe.getCustomer(setupIntent.metadata.customer_id);
|
|
const member = await users.get({email: customer.email});
|
|
|
|
await stripe.handleCheckoutSetupSessionCompletedWebhook(setupIntent, member);
|
|
} else if (event.data.object.mode === 'subscription') {
|
|
common.logging.info('Handling "subscription" mode Checkout Session');
|
|
const customer = await stripe.getCustomer(event.data.object.customer, {
|
|
expand: ['subscriptions.data.default_payment_method']
|
|
});
|
|
let member = await users.get({email: customer.email});
|
|
if (!member) {
|
|
const metadataName = _.get(event, 'data.object.metadata.name');
|
|
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
|
|
const name = metadataName || payerName || null;
|
|
member = await users.create({email: customer.email, name});
|
|
} else {
|
|
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
|
|
|
|
if (payerName && !member.get('name')) {
|
|
await users.update({name: payerName}, {id: member.get('id')});
|
|
}
|
|
}
|
|
|
|
await stripe.handleCheckoutSessionCompletedWebhook(member, customer);
|
|
|
|
const emailType = 'signup';
|
|
await sendEmailWithMagicLink({email: customer.email, requestedType: emailType, options: {forceEmailType: true}});
|
|
} else if (event.data.object.mode === 'payment') {
|
|
common.logging.info('Ignoring "payment" mode Checkout Session');
|
|
}
|
|
}
|
|
|
|
res.writeHead(200);
|
|
res.end();
|
|
} catch (err) {
|
|
common.logging.error(`Error handling webhook ${event.type}`, err);
|
|
res.writeHead(400);
|
|
res.end();
|
|
}
|
|
});
|
|
|
|
middleware.updateSubscription.use(ensureStripe, body.json(), async function (req, res) {
|
|
const identity = req.body.identity;
|
|
const cancelAtPeriodEnd = req.body.cancel_at_period_end;
|
|
const planName = req.body.planName;
|
|
const subscriptionId = req.params.id;
|
|
|
|
let member;
|
|
|
|
try {
|
|
if (!identity) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'Updating subscription failed! Could not find member'
|
|
});
|
|
}
|
|
|
|
const claims = await decodeToken(identity);
|
|
const email = claims.sub;
|
|
member = email ? await users.get({email}, {withRelated: ['stripeSubscriptions']}) : null;
|
|
|
|
if (!member) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'Updating subscription failed! Could not find member'
|
|
});
|
|
}
|
|
} catch (err) {
|
|
res.writeHead(401);
|
|
return res.end('Unauthorized');
|
|
}
|
|
// Don't allow removing subscriptions that don't belong to the member
|
|
const plan = planName && stripe.findPlanByNickname(planName);
|
|
if (planName && !plan) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'Updating subscription failed! Could not find plan'
|
|
});
|
|
}
|
|
const subscription = member.related('stripeSubscriptions').models.find(
|
|
subscription => subscription.get('subscription_id') === subscriptionId
|
|
);
|
|
if (!subscription) {
|
|
res.writeHead(403);
|
|
return res.end('No permission');
|
|
}
|
|
|
|
if (cancelAtPeriodEnd === undefined && planName === undefined) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'Updating subscription failed!',
|
|
help: 'Request should contain "cancel" or "plan" field.'
|
|
});
|
|
}
|
|
const subscriptionUpdate = {
|
|
id: subscription.get('subscription_id')
|
|
};
|
|
if (cancelAtPeriodEnd !== undefined) {
|
|
subscriptionUpdate.cancel_at_period_end = !!(cancelAtPeriodEnd);
|
|
}
|
|
|
|
if (plan) {
|
|
subscriptionUpdate.plan = plan.id;
|
|
}
|
|
|
|
await stripe.updateSubscriptionFromClient(subscriptionUpdate);
|
|
|
|
res.writeHead(204);
|
|
res.end();
|
|
});
|
|
|
|
const getPublicConfig = function () {
|
|
return Promise.resolve({
|
|
publicKey,
|
|
issuer
|
|
});
|
|
};
|
|
|
|
const bus = new (require('events').EventEmitter)();
|
|
|
|
if (stripe) {
|
|
stripe.ready().then(() => {
|
|
bus.emit('ready');
|
|
}).catch((err) => {
|
|
bus.emit('error', err);
|
|
});
|
|
} else {
|
|
process.nextTick(() => bus.emit('ready'));
|
|
}
|
|
|
|
return {
|
|
middleware,
|
|
getMemberDataFromMagicLinkToken,
|
|
getMemberIdentityToken,
|
|
getMemberIdentityData,
|
|
setMemberGeolocationFromIp,
|
|
getPublicConfig,
|
|
bus,
|
|
sendEmailWithMagicLink,
|
|
getMagicLink,
|
|
hasActiveStripeSubscriptions,
|
|
members: users
|
|
};
|
|
};
|