fac6c3d97e
refs https://github.com/TryGhost/members.js/issues/10 - Allows passing an additional `customerEmail` value to our checkout creation API - This value is used to pass `customer_email` option to stripe's checkout session - https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-customer_email. The `customer_email` allows pre-filling the customer's email field in case of an anonymous checkout as customer doesn't exist already, and also ensures the stripe subscription is created with same email address as given by user during signup flow.
409 lines
13 KiB
JavaScript
409 lines
13 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,
|
|
secret
|
|
},
|
|
paymentConfig,
|
|
mail: {
|
|
transporter,
|
|
getText,
|
|
getHTML,
|
|
getSubject
|
|
},
|
|
memberStripeCustomerModel,
|
|
stripeCustomerSubscriptionModel,
|
|
memberModel,
|
|
logger
|
|
}) {
|
|
if (logger) {
|
|
common.logging.setLogger(logger);
|
|
}
|
|
|
|
const {encodeIdentityToken, decodeToken} = Tokens({privateKey, publicKey, issuer});
|
|
const metadata = Metadata({memberStripeCustomerModel, stripeCustomerSubscriptionModel});
|
|
|
|
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,
|
|
secret,
|
|
getSigninURL,
|
|
getText,
|
|
getHTML,
|
|
getSubject
|
|
});
|
|
|
|
async function sendEmailWithMagicLink({email, requestedType, payload, options = {forceEmailType: false}}){
|
|
if (options.forceEmailType) {
|
|
return magicLinkService.sendMagicLink({email, payload, subject: email, type: requestedType});
|
|
}
|
|
const member = await users.get({email});
|
|
if (member) {
|
|
return magicLinkService.sendMagicLink({email, payload, subject: email, type: 'signin'});
|
|
} else {
|
|
const type = requestedType === 'subscribe' ? 'subscribe' : 'signup';
|
|
return magicLinkService.sendMagicLink({email, payload, subject: email, type});
|
|
}
|
|
}
|
|
|
|
function getMagicLink(email) {
|
|
return magicLinkService.getMagicLink({email, subject: email, type: 'signin'});
|
|
}
|
|
|
|
const users = Users({
|
|
stripe,
|
|
memberModel
|
|
});
|
|
|
|
async function getMemberDataFromMagicLinkToken(token) {
|
|
const email = await magicLinkService.getUserFromToken(token);
|
|
const {labels = [], ip} = await magicLinkService.getPayloadFromToken(token);
|
|
|
|
if (!email) {
|
|
return null;
|
|
}
|
|
|
|
const member = await getMemberIdentityData(email);
|
|
|
|
let geolocation;
|
|
if (ip && (!member || !member.geolocation)) {
|
|
try {
|
|
// max request time is 500ms so shouldn't slow requests down too much
|
|
geolocation = JSON.stringify(await getGeolocationFromIP(ip));
|
|
} catch (err) {
|
|
// no-op, we don't want to stop anything working due to
|
|
// geolocation lookup failing but logs can be useful
|
|
common.logging.warn(err);
|
|
}
|
|
}
|
|
|
|
if (member) {
|
|
// user exists but doesn't have geolocation yet so update it
|
|
if (geolocation) {
|
|
member.geolocation = geolocation;
|
|
await users.update(member, {id: member.id});
|
|
return getMemberIdentityData(email);
|
|
}
|
|
return member;
|
|
}
|
|
|
|
await users.create({email, labels, geolocation});
|
|
return getMemberIdentityData(email);
|
|
}
|
|
async function getMemberIdentityData(email){
|
|
return users.get({email});
|
|
}
|
|
async function getMemberIdentityToken(email){
|
|
const member = await getMemberIdentityData(email);
|
|
if (!member) {
|
|
return null;
|
|
}
|
|
return encodeIdentityToken({sub: member.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 {ip, body} = req;
|
|
const {email, emailType} = body;
|
|
const payload = {ip};
|
|
|
|
if (!email) {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
try {
|
|
if (!allowSelfSignup) {
|
|
const member = await users.get({email});
|
|
if (member) {
|
|
await sendEmailWithMagicLink({email, requestedType: emailType, payload});
|
|
}
|
|
} else {
|
|
if (body.labels) {
|
|
payload.labels = body.labels;
|
|
}
|
|
await sendEmailWithMagicLink({email, requestedType: emailType, payload});
|
|
}
|
|
res.writeHead(201);
|
|
return res.end('Created.');
|
|
} catch (err) {
|
|
common.logging.error(err);
|
|
res.writeHead(500);
|
|
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}) : null;
|
|
|
|
// Do not allow members already with a subscription to initiate a new checkout session
|
|
if (member && member.stripe.subscriptions.length > 0) {
|
|
res.writeHead(403);
|
|
return res.end('No permission');
|
|
}
|
|
|
|
const sessionInfo = await stripe.createCheckoutSession(member, plan, {
|
|
successUrl: req.body.successUrl,
|
|
cancelUrl: req.body.cancelUrl,
|
|
customerEmail: req.body.customerEmail
|
|
});
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
|
|
res.end(JSON.stringify(sessionInfo));
|
|
});
|
|
|
|
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();
|
|
}
|
|
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 === '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.setup_intent) {
|
|
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 {
|
|
const customer = await stripe.getCustomer(event.data.object.customer, {
|
|
expand: ['subscriptions.data.default_payment_method']
|
|
});
|
|
|
|
const member = await users.get({email: customer.email}) || await users.create({email: customer.email});
|
|
await stripe.handleCheckoutSessionCompletedWebhook(member, customer);
|
|
|
|
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
|
|
|
|
if (payerName && !member.name) {
|
|
await users.update({name: payerName}, {id: member.id});
|
|
}
|
|
|
|
const emailType = 'signup';
|
|
await sendEmailWithMagicLink({email: customer.email, requestedType: emailType, options: {forceEmailType: true}});
|
|
}
|
|
}
|
|
|
|
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 subscriptionId = req.params.id;
|
|
|
|
let member;
|
|
|
|
try {
|
|
if (!identity) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'Cancel membership failed! Could not find member'
|
|
});
|
|
}
|
|
|
|
const claims = await decodeToken(identity);
|
|
const email = claims.sub;
|
|
member = email ? await users.get({email}) : null;
|
|
|
|
if (!member) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'Cancel membership 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 subscription = member.stripe.subscriptions.find(sub => sub.id === subscriptionId);
|
|
|
|
if (!subscription) {
|
|
res.writeHead(403);
|
|
return res.end('No permission');
|
|
}
|
|
|
|
if (cancelAtPeriodEnd === undefined) {
|
|
throw new common.errors.BadRequestError({
|
|
message: 'Canceling membership failed!',
|
|
help: 'Request should contain boolean "cancel" field.'
|
|
});
|
|
}
|
|
|
|
subscription.cancel_at_period_end = !!(cancelAtPeriodEnd);
|
|
|
|
await stripe.updateSubscriptionFromClient(subscription);
|
|
|
|
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,
|
|
getPublicConfig,
|
|
bus,
|
|
sendEmailWithMagicLink,
|
|
getMagicLink,
|
|
members: users
|
|
};
|
|
};
|