mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 14:03:48 +03:00
e3ef01932f
no-issue This refactors the members-api module so that it is easier to test going forward, as well as easier to understand & navigate. The Stripe API no longer contains storage code, this is all handled via the member repository. And we have dedicated services for webhooks, and stripe plans initialisation.
323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
const common = require('../../../lib/common');
|
|
const _ = require('lodash');
|
|
const errors = require('ghost-ignition').errors;
|
|
|
|
/**
|
|
* RouterController
|
|
*
|
|
* @param {object} deps
|
|
* @param {any} deps.memberRepository
|
|
* @param {boolean} deps.allowSelfSignup
|
|
* @param {any} deps.magicLinkService
|
|
* @param {any} deps.stripeAPIService
|
|
* @param {any} deps.stripePlanService
|
|
* @param {any} deps.tokenService
|
|
*/
|
|
module.exports = class RouterController {
|
|
constructor({
|
|
memberRepository,
|
|
allowSelfSignup,
|
|
magicLinkService,
|
|
stripeAPIService,
|
|
stripePlansService,
|
|
tokenService,
|
|
sendEmailWithMagicLink
|
|
}) {
|
|
this._memberRepository = memberRepository;
|
|
this._allowSelfSignup = allowSelfSignup;
|
|
this._magicLinkService = magicLinkService;
|
|
this._stripeAPIService = stripeAPIService;
|
|
this._stripePlansService = stripePlansService;
|
|
this._tokenService = tokenService;
|
|
this._sendEmailWithMagicLink = sendEmailWithMagicLink;
|
|
}
|
|
|
|
async ensureStripe(_req, res, next) {
|
|
if (!this._stripeAPIService) {
|
|
res.writeHead(400);
|
|
return res.end('Stripe not configured');
|
|
}
|
|
try {
|
|
await this._stripeAPIService.ready();
|
|
next();
|
|
} catch (err) {
|
|
res.writeHead(500);
|
|
return res.end('There was an error configuring stripe');
|
|
}
|
|
}
|
|
|
|
async updateSubscription(req, res) {
|
|
const identity = req.body.identity;
|
|
const subscriptionId = req.params.id;
|
|
const cancelAtPeriodEnd = req.body.cancel_at_period_end;
|
|
const cancellationReason = req.body.cancellation_reason;
|
|
const planName = req.body.planName;
|
|
|
|
if (cancelAtPeriodEnd === undefined && planName === undefined) {
|
|
throw new errors.BadRequestError({
|
|
message: 'Updating subscription failed!',
|
|
help: 'Request should contain "cancel_at_period_end" or "planName" field.'
|
|
});
|
|
}
|
|
|
|
if ((cancelAtPeriodEnd === undefined || cancelAtPeriodEnd === false) && cancellationReason !== undefined) {
|
|
throw new errors.BadRequestError({
|
|
message: 'Updating subscription failed!',
|
|
help: '"cancellation_reason" field requires the "cancel_at_period_end" field to be true.'
|
|
});
|
|
}
|
|
|
|
if (cancellationReason && cancellationReason.length > 500) {
|
|
throw new errors.BadRequestError({
|
|
message: 'Updating subscription failed!',
|
|
help: '"cancellation_reason" field can be a maximum of 500 characters.'
|
|
});
|
|
}
|
|
|
|
let email;
|
|
try {
|
|
if (!identity) {
|
|
throw new errors.BadRequestError({
|
|
message: 'Updating subscription failed! Could not find member'
|
|
});
|
|
}
|
|
|
|
const claims = await this._tokenService.decodeToken(identity);
|
|
email = claims && claims.sub;
|
|
} catch (err) {
|
|
res.writeHead(401);
|
|
return res.end('Unauthorized');
|
|
}
|
|
|
|
const member = email ? await this._memberRepository.get({email}, {withRelated: ['stripeSubscriptions']}) : null;
|
|
|
|
if (!member) {
|
|
throw new errors.BadRequestError({
|
|
message: 'Updating subscription failed! Could not find member'
|
|
});
|
|
}
|
|
|
|
// Don't allow removing subscriptions that don't belong to the member
|
|
const subscription = member.related('stripeSubscriptions').models.find(
|
|
subscription => subscription.get('subscription_id') === subscriptionId
|
|
);
|
|
if (!subscription) {
|
|
res.writeHead(403);
|
|
return res.end('No permission');
|
|
}
|
|
|
|
let updatedSubscription;
|
|
if (planName !== undefined) {
|
|
const plan = this._stripePlansService.getPlans().find(plan => plan.nickname === planName);
|
|
if (!plan) {
|
|
throw new errors.BadRequestError({
|
|
message: 'Updating subscription failed! Could not find plan'
|
|
});
|
|
}
|
|
updatedSubscription = await this._stripeAPIService.changeSubscriptionPlan(subscriptionId, plan.id);
|
|
} else if (cancelAtPeriodEnd !== undefined) {
|
|
if (cancelAtPeriodEnd) {
|
|
updatedSubscription = await this._stripeAPIService.cancelSubscriptionAtPeriodEnd(
|
|
subscriptionId, cancellationReason
|
|
);
|
|
} else {
|
|
updatedSubscription = await this._stripeAPIService.continueSubscriptionAtPeriodEnd(
|
|
subscriptionId
|
|
);
|
|
}
|
|
}
|
|
if (updatedSubscription) {
|
|
await this._memberRepository.linkSubscription({
|
|
id: member.id,
|
|
subscription: updatedSubscription
|
|
});
|
|
}
|
|
|
|
res.writeHead(204);
|
|
res.end();
|
|
}
|
|
|
|
async createCheckoutSetupSession(req, res) {
|
|
const identity = req.body.identity;
|
|
|
|
let email;
|
|
try {
|
|
if (!identity) {
|
|
email = null;
|
|
} else {
|
|
const claims = await this._tokenService.decodeToken(identity);
|
|
email = claims && claims.sub;
|
|
}
|
|
} catch (err) {
|
|
res.writeHead(401);
|
|
return res.end('Unauthorized');
|
|
}
|
|
|
|
const member = email ? await this._memberRepository.get({email}) : null;
|
|
|
|
if (!member) {
|
|
res.writeHead(403);
|
|
return res.end('Bad Request.');
|
|
}
|
|
const customer = await this._stripeAPIService.getCustomerForMemberCheckoutSession(member);
|
|
|
|
const session = await this._stripeAPIService.createCheckoutSetupSession(customer, {
|
|
successUrl: req.body.successUrl,
|
|
cancelUrl: req.body.cancelUrl
|
|
});
|
|
const publicKey = this._stripeAPIService.getPublicKey();
|
|
const sessionInfo = {
|
|
sessionId: session.id,
|
|
publicKey
|
|
};
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
|
|
res.end(JSON.stringify(sessionInfo));
|
|
}
|
|
|
|
async createCheckoutSession(req, res) {
|
|
const planName = req.body.plan;
|
|
const identity = req.body.identity;
|
|
|
|
if (!planName) {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
// NOTE: never allow "Complimentary" plan to be subscribed to from the client
|
|
if (planName.toLowerCase() === 'complimentary') {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
const plan = this._stripePlansService.getPlan(planName);
|
|
|
|
let email;
|
|
try {
|
|
if (!identity) {
|
|
email = null;
|
|
} else {
|
|
const claims = await this._tokenService.decodeToken(identity);
|
|
email = claims && claims.sub;
|
|
}
|
|
} catch (err) {
|
|
res.writeHead(401);
|
|
return res.end('Unauthorized');
|
|
}
|
|
|
|
const member = email ? await this._memberRepository.get({email}, {withRelated: ['stripeCustomers', 'stripeSubscriptions']}) : null;
|
|
|
|
if (!member) {
|
|
const customer = null;
|
|
const session = await this._stripeAPIService.createCheckoutSession(plan, customer, {
|
|
successUrl: req.body.successUrl,
|
|
cancelUrl: req.body.cancelUrl,
|
|
customerEmail: req.body.customerEmail,
|
|
metadata: req.body.metadata
|
|
});
|
|
const publicKey = this._stripeAPIService.getPublicKey();
|
|
|
|
const sessionInfo = {
|
|
publicKey,
|
|
sessionId: session.id
|
|
};
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
|
|
return res.end(JSON.stringify(sessionInfo));
|
|
}
|
|
|
|
for (const subscription of member.related('stripeSubscriptions')) {
|
|
if (['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.get('status'))) {
|
|
res.writeHead(403);
|
|
return res.end('No permission');
|
|
}
|
|
}
|
|
|
|
let stripeCustomer;
|
|
|
|
for (const customer of member.related('stripeCustomers').models) {
|
|
try {
|
|
const fetchedCustomer = await this._stripeAPIService.getCustomer(customer.get('customer_id'));
|
|
if (!fetchedCustomer.deleted) {
|
|
stripeCustomer = fetchedCustomer;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
console.log('Ignoring error for fetching customer for checkout');
|
|
}
|
|
}
|
|
|
|
if (!stripeCustomer) {
|
|
stripeCustomer = await this._stripeAPIService.createCustomer({email: member.email});
|
|
}
|
|
|
|
try {
|
|
const session = await this._stripeAPIService.createCheckoutSession(plan, stripeCustomer, {
|
|
successUrl: req.body.successUrl,
|
|
cancelUrl: req.body.cancelUrl,
|
|
metadata: req.body.metadata
|
|
});
|
|
const publicKey = this._stripeAPIService.getPublicKey();
|
|
|
|
const sessionInfo = {
|
|
publicKey,
|
|
sessionId: session.id
|
|
};
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
|
|
return res.end(JSON.stringify(sessionInfo));
|
|
} catch (e) {
|
|
const error = e.message || 'Unable to initiate checkout session';
|
|
res.writeHead(400);
|
|
return res.end(error);
|
|
}
|
|
}
|
|
|
|
async sendMagicLink(req, res) {
|
|
const {email, emailType, oldEmail, requestSrc} = req.body;
|
|
let forceEmailType = false;
|
|
if (!email) {
|
|
res.writeHead(400);
|
|
return res.end('Bad Request.');
|
|
}
|
|
|
|
try {
|
|
if (oldEmail) {
|
|
const existingMember = await this._memberRepository.get({email});
|
|
if (existingMember) {
|
|
throw new errors.BadRequestError({
|
|
message: 'This email is already associated with a member'
|
|
});
|
|
}
|
|
forceEmailType = true;
|
|
}
|
|
|
|
if (!this._allowSelfSignup) {
|
|
const member = oldEmail ? await this._memberRepository.get({oldEmail}) : await this._memberRepository.get({email});
|
|
if (member) {
|
|
const tokenData = _.pick(req.body, ['oldEmail']);
|
|
await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, requestSrc, options: {forceEmailType}});
|
|
}
|
|
} else {
|
|
const tokenData = _.pick(req.body, ['labels', 'name', 'oldEmail']);
|
|
await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, requestSrc, options: {forceEmailType}});
|
|
}
|
|
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.');
|
|
}
|
|
}
|
|
};
|