0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Refactor members-api ()

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.
This commit is contained in:
Fabien 'egg' O'Carroll 2021-01-18 13:55:40 +00:00 committed by GitHub
parent af13570076
commit e3ef01932f
22 changed files with 1728 additions and 1480 deletions

2
ghost/members-api/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
types
tsconfig.json

View file

@ -1,13 +1,15 @@
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');
const StripeAPIService = require('./lib/services/stripe-api');
const StripePlansService = require('./lib/services/stripe-plans');
const StripeWebhookService = require('./lib/services/stripe-webhook');
const TokenService = require('./lib/services/token');
const GeolocationSerice = require('./lib/services/geolocation');
const MemberRepository = require('./lib/repositories/member');
const RouterController = require('./lib/controllers/router');
module.exports = function MembersApi({
tokenConfig: {
@ -39,14 +41,77 @@ module.exports = function MembersApi({
common.logging.setLogger(logger);
}
const {encodeIdentityToken, decodeToken} = Tokens({privateKey, publicKey, issuer});
const metadata = Metadata({
const stripeConfig = paymentConfig && paymentConfig.stripe || {};
const stripeAPIService = new StripeAPIService({
config: {
secretKey: stripeConfig.secretKey,
publicKey: stripeConfig.publicKey,
appInfo: stripeConfig.appInfo,
enablePromoCodes: stripeConfig.enablePromoCodes
},
logger
});
const stripePlansService = new StripePlansService({
stripeAPIService
});
const memberRepository = new MemberRepository({
stripeAPIService,
stripePlansService,
logger,
Member,
StripeWebhook,
StripeCustomer,
StripeCustomerSubscription
});
const stripeWebhookService = new StripeWebhookService({
StripeWebhook,
stripeAPIService,
memberRepository,
sendEmailWithMagicLink
});
const tokenService = new TokenService({
privateKey,
publicKey,
issuer
});
const geolocationService = new GeolocationSerice();
const magicLinkService = new MagicLink({
transporter,
tokenProvider,
getSigninURL,
getText,
getHTML,
getSubject
});
const routerController = new RouterController({
memberRepository,
allowSelfSignup,
magicLinkService,
stripeAPIService,
stripePlansService,
tokenService,
sendEmailWithMagicLink
});
const ready = paymentConfig.stripe ? Promise.all([
stripePlansService.configure({
product: stripeConfig.product,
plans: stripeConfig.plans
}),
stripeWebhookService.configure({
webhookSecret: process.env.WEBHOOK_SECRET,
webhookHandlerUrl: stripeConfig.webhookHandlerUrl,
webhook: stripeConfig.webhook || {}
})
]) : Promise.resolve();
async function hasActiveStripeSubscriptions() {
const firstActiveSubscription = await StripeCustomerSubscription.findOne({
status: 'active'
@ -83,45 +148,7 @@ module.exports = function MembersApi({
return false;
}
const stripeStorage = {
async get(member) {
return metadata.getMetadata('stripe', member);
},
async set(data, options) {
return metadata.setMetadata('stripe', data, options);
}
};
/** @type {StripePaymentProcessor} */
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,
StripeCustomer
});
const users = memberRepository;
async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, requestSrc = ''}) {
let type = requestedType;
@ -177,7 +204,7 @@ module.exports = function MembersApi({
if (!member) {
return null;
}
return encodeIdentityToken({sub: member.email});
return tokenService.encodeIdentityToken({sub: member.email});
}
async function setMemberGeolocationFromIp(email, ip) {
@ -198,7 +225,7 @@ module.exports = function MembersApi({
}
// max request time is 500ms so shouldn't slow requests down too much
let geolocation = JSON.stringify(await getGeolocationFromIP(ip));
let geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(ip));
if (geolocation) {
member.geolocation = geolocation;
await users.update(member, {id: member.id});
@ -208,147 +235,34 @@ module.exports = function MembersApi({
}
const middleware = {
sendMagicLink: Router(),
createCheckoutSession: Router(),
createCheckoutSetupSession: Router(),
handleStripeWebhook: Router(),
updateSubscription: Router({mergeParams: true})
sendMagicLink: Router().use(
body.json(),
(req, res) => routerController.sendMagicLink(req, res)
),
createCheckoutSession: Router().use(
body.json(),
(req, res) => routerController.createCheckoutSession(req, res)
),
createCheckoutSetupSession: Router().use(
body.json(),
(req, res) => routerController.createCheckoutSetupSession(req, res)
),
updateSubscription: Router({mergeParams: true}).use(
body.json(),
(req, res) => routerController.updateSubscription(req, res)
),
handleStripeWebhook: Router()
};
middleware.sendMagicLink.use(body.json(), async function (req, res) {
const {email, emailType, oldEmail, requestSrc} = req.body;
let forceEmailType = false;
if (!email) {
middleware.handleStripeWebhook.use(body.raw({type: 'application/json'}), async function (req, res) {
if (!stripeAPIService) {
common.logging.error(`Stripe not configured, not handling webhook`);
res.writeHead(400);
return res.end('Bad Request.');
return res.end();
}
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'
});
}
forceEmailType = true;
}
if (!allowSelfSignup) {
const member = oldEmail ? await users.get({oldEmail}) : await users.get({email});
if (member) {
const tokenData = _.pick(req.body, ['oldEmail']);
await sendEmailWithMagicLink({email, tokenData, requestedType: emailType, requestSrc, options: {forceEmailType}});
}
} else {
const tokenData = _.pick(req.body, ['labels', 'name', 'oldEmail']);
await 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.');
}
});
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 "Complimentary" 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 && 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 && 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']);
event = stripeWebhookService.parseWebhook(req.body, req.headers['stripe-signature']);
} catch (err) {
common.logging.error(err);
res.writeHead(401);
@ -356,65 +270,7 @@ module.exports = function MembersApi({
}
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});
const checkoutType = _.get(event, 'data.object.metadata.checkoutType');
const requestSrc = _.get(event, 'data.object.metadata.requestSrc') || '';
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);
if (checkoutType !== 'upgrade') {
const emailType = 'signup';
await sendEmailWithMagicLink({email: customer.email, requestedType: emailType, requestSrc, options: {forceEmailType: true}, tokenData: {}});
}
} else if (event.data.object.mode === 'payment') {
common.logging.info('Ignoring "payment" mode Checkout Session');
}
}
await stripeWebhookService.handleWebhook(event);
res.writeHead(200);
res.end();
} catch (err) {
@ -424,90 +280,6 @@ module.exports = function MembersApi({
}
});
middleware.updateSubscription.use(ensureStripe, body.json(), async function (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 common.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 common.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 common.errors.BadRequestError({
message: 'Updating subscription failed!',
help: '"cancellation_reason" field can be a maximum of 500 characters.'
});
}
let email;
try {
if (!identity) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed! Could not find member'
});
}
const claims = await decodeToken(identity);
email = claims && claims.sub;
} catch (err) {
res.writeHead(401);
return res.end('Unauthorized');
}
const member = email ? await users.get({email}, {withRelated: ['stripeSubscriptions']}) : null;
if (!member) {
throw new common.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');
}
const subscriptionUpdateData = {
id: subscriptionId
};
if (cancelAtPeriodEnd !== undefined) {
subscriptionUpdateData.cancel_at_period_end = cancelAtPeriodEnd;
subscriptionUpdateData.cancellation_reason = cancellationReason;
}
if (planName !== undefined) {
const plan = stripe.findPlanByNickname(planName);
if (!plan) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed! Could not find plan'
});
}
subscriptionUpdateData.plan = plan.id;
}
await stripe.updateSubscriptionFromClient(subscriptionUpdateData);
res.writeHead(204);
res.end();
});
const getPublicConfig = function () {
return Promise.resolve({
publicKey,
@ -517,15 +289,11 @@ module.exports = function MembersApi({
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'));
}
ready.then(() => {
bus.emit('ready');
}).catch((err) => {
bus.emit('error', err);
});
return {
middleware,

View file

@ -0,0 +1,322 @@
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.');
}
}
};

View file

@ -1,18 +0,0 @@
const got = require('got');
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
const getGeolocationFromIP = async function getGeolocationFromIP(ipAddress) {
if (!ipAddress || (!IPV4_REGEX.test(ipAddress) && !IPV6_REGEX.test(ipAddress))) {
return;
}
const geojsUrl = `https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ipAddress)}.json`;
const response = await got(geojsUrl, {json: true, timeout: 500});
return response.body;
};
module.exports = {
getGeolocationFromIP
};

View file

@ -1,73 +0,0 @@
module.exports = function ({
Member,
StripeWebhook,
StripeCustomer,
StripeCustomerSubscription
}) {
async function setMetadata(module, metadata, options = {}) {
if (module !== 'stripe') {
return;
}
if (metadata.customer) {
const member = await Member.findOne({
id: metadata.customer.member_id
}, options);
if (member) {
await StripeCustomer.upsert(metadata.customer, {
...options,
customer_id: metadata.customer.customer_id
});
}
}
if (metadata.subscription) {
const customer = await StripeCustomer.findOne({
customer_id: metadata.subscription.customer_id
}, options);
if (customer) {
await StripeCustomerSubscription.upsert(metadata.subscription, {
...options,
subscription_id: metadata.subscription.subscription_id
});
}
}
if (metadata.webhook) {
await StripeWebhook.upsert(metadata.webhook, {
...options,
webhook_id: metadata.webhook.webhook_id
});
}
return;
}
async function getMetadata(module, member) {
if (module !== 'stripe') {
return;
}
if (!member.relations.stripeCustomers) {
await member.load(['stripeCustomers']);
}
if (!member.relations.stripeSubscriptions) {
await member.load(['stripeSubscriptions', 'stripeSubscriptions.customer']);
}
const customers = member.related('stripeCustomers').toJSON();
const subscriptions = member.related('stripeSubscriptions').toJSON();
return {
customers: customers,
subscriptions: subscriptions
};
}
return {
setMetadata,
getMetadata
};
};

View file

@ -0,0 +1,358 @@
const _ = require('lodash');
module.exports = class MemberRepository {
/**
* @param {object} deps
* @param {any} deps.Member
* @param {any} deps.StripeCustomer
* @param {any} deps.StripeCustomerSubscription
* @param {import('../../services/stripe-api')} deps.stripeAPIService
* @param {import('../../services/stripe-plans')} deps.stripePlansService
* @param {any} deps.logger
*/
constructor({
Member,
StripeCustomer,
StripeCustomerSubscription,
stripeAPIService,
stripePlansService,
logger
}) {
this._Member = Member;
this._StripeCustomer = StripeCustomer;
this._StripeCustomerSubscription = StripeCustomerSubscription;
this._stripeAPIService = stripeAPIService;
this._stripePlansService = stripePlansService;
this._logging = logger;
}
async get(data, options) {
if (data.customer_id) {
const customer = await this._StripeCustomer.findOne({
customer_id: data.customer_id
}, {
withRelated: ['member']
});
if (customer) {
return customer.related('member');
}
return null;
}
return this._Member.findOne(data, options);
}
async create(data, options) {
const {labels} = data;
if (labels) {
labels.forEach((label, index) => {
if (typeof label === 'string') {
labels[index] = {name: label};
}
});
}
// @NOTE: Use _.pick
return this._Member.add({
labels,
email: data.email,
name: data.name,
note: data.note,
subscribed: data.subscribed,
geolocation: data.geolocation,
created_at: data.created_at
}, options);
}
async update(data, options) {
const member = await this._Member.edit(_.pick(data, [
'email',
'name',
'note',
'subscribed',
'labels',
'geolocation'
]), options);
if (this._stripeAPIService && member._changed.email) {
await member.related('stripeCustomers').fetch();
const customers = member.related('stripeCustomers');
for (const customer of customers.models) {
await this._stripeAPIService.updateCustomerEmail(
customer.get('customer_id'),
member.get('email')
);
}
}
return member;
}
async list(options) {
return this._Member.findPage(options);
}
async destroy(data, options) {
const member = await this._Member.findOne(data, options);
if (!member) {
// throw error?
return;
}
if (this._stripeAPIService && options.cancelStripeSubscriptions) {
await member.related('stripeSubscriptions');
const subscriptions = member.related('stripeSubscriptions');
for (const subscription of subscriptions.models) {
if (subscription.get('status') !== 'canceled') {
const updatedSubscription = await this._stripeAPIService.cancelSubscription(
subscription.get('subscription_id')
);
await this._StripeCustomerSubscription.update({
status: updatedSubscription.status
});
}
}
}
return this._Member.destroy({
id: data.id
}, options);
}
async upsertCustomer(data) {
return await this._StripeCustomer.upsert({
customer_id: data.customer_id,
member_id: data.member_id,
name: data.name,
email: data.email
});
}
async linkStripeCustomer(data) {
if (!this._stripeAPIService) {
return;
}
const customer = await this._stripeAPIService.getCustomer(data.customer_id);
if (!customer) {
return;
}
// Add instead of upsert ensures that we do not link existing customer
await this._StripeCustomer.add({
customer_id: data.customer_id,
member_id: data.member_id,
name: customer.name,
email: customer.email
});
for (const subscription of customer.subscriptions.data) {
await this.linkSubscription({
id: data.member_id,
subscription
});
}
}
async linkSubscription(data) {
if (!this._stripeAPIService) {
return;
}
const member = await this._Member.findOne({
id: data.id
});
const customer = await member.related('stripeCustomers').query({
where: {
customer_id: data.subscription.customer
}
}).fetchOne();
if (!customer) {
// Maybe just link the customer?
throw new Error('Subscription is not associated with a customer for the member');
}
const subscription = data.subscription;
let paymentMethodId;
if (!subscription.default_payment_method) {
paymentMethodId = null;
} else if (typeof subscription.default_payment_method === 'string') {
paymentMethodId = subscription.default_payment_method;
} else {
paymentMethodId = subscription.default_payment_method.id;
}
const paymentMethod = paymentMethodId ? await this._stripeAPIService.getCardPaymentMethod(paymentMethodId) : null;
await this._StripeCustomerSubscription.upsert({
customer_id: subscription.customer,
subscription_id: subscription.id,
status: subscription.status,
cancel_at_period_end: subscription.cancel_at_period_end,
cancellation_reason: subscription.metadata && subscription.metadata.cancellation_reason || null,
current_period_end: new Date(subscription.current_period_end * 1000),
start_date: new Date(subscription.start_date * 1000),
default_payment_card_last4: paymentMethod && paymentMethod.card && paymentMethod.card.last4 || null,
plan_id: subscription.plan.id,
// NOTE: Defaulting to interval as migration to nullable field
// turned out to be much bigger problem.
// Ideally, would need nickname field to be nullable on the DB level
// condition can be simplified once this is done
plan_nickname: subscription.plan.nickname || subscription.plan.interval,
plan_interval: subscription.plan.interval,
plan_amount: subscription.plan.amount,
plan_currency: subscription.plan.currency
}, {
subscription_id: subscription.id
});
}
async updateSubscription(data) {
if (!this._stripeAPIService) {
return;
}
const member = await this._Member.findOne({
id: data.id
});
const subscription = await member.related('stripeSubscriptions').query({
where: {
subscription_id: data.subscription.subscription_id
}
}).fetchOne();
if (!subscription) {
throw new Error('Subscription not found');
}
if (data.subscription.cancel_at_period_end === undefined) {
throw new Error('Incorrect usage');
}
if (data.subscription.cancel_at_period_end) {
await this._stripeAPIService.cancelSubscriptionAtPeriodEnd(data.subscription.subscription_id);
} else {
await this._stripeAPIService.continueSubscriptionAtPeriodEnd(data.subscription.subscription_id);
}
await this._StripeCustomerSubscription.edit({
subscription_id: data.subscription.subscription_id,
cancel_at_period_end: data.subscription.cancel_at_period_end
}, {
id: subscription.id
});
}
async setComplimentarySubscription(data) {
if (!this._stripeAPIService) {
return;
}
const member = await this._Member.findOne({
id: data.id
});
const subscriptions = await member.related('stripeSubscriptions').fetch();
const activeSubscriptions = subscriptions.models.filter((subscription) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.get('status'));
});
// NOTE: Because we allow for multiple Complimentary plans, need to take into account currently availalbe
// plan currencies so that we don't end up giving a member complimentary subscription in wrong currency.
// Giving member a subscription in different currency would prevent them from resubscribing with a regular
// plan if Complimentary is cancelled (ref. https://stripe.com/docs/billing/customer#currency)
let complimentaryCurrency = this._stripePlansService.getPlans().find(plan => plan.interval === 'month').currency.toLowerCase();
if (activeSubscriptions.length) {
complimentaryCurrency = activeSubscriptions[0].get('plan_currency').toLowerCase();
}
const complimentaryPlan = this._stripePlansService.getComplimentaryPlan(complimentaryCurrency);
if (!complimentaryPlan) {
throw new Error('Could not find Complimentary plan');
}
let stripeCustomer;
await member.related('stripeCustomers').fetch();
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.get('email')
});
await this._StripeCustomer.upsert({
customer_id: stripeCustomer.id,
member_id: data.id,
email: stripeCustomer.email,
name: stripeCustomer.name
});
}
if (!subscriptions.length) {
const subscription = await this._stripeAPIService.createSubscription(stripeCustomer.id, complimentaryPlan.id);
await this.linkSubscription({
id: member.id,
subscription
});
} else {
// NOTE: we should only ever have 1 active subscription, but just in case there is more update is done on all of them
for (const subscription of activeSubscriptions) {
const updatedSubscription = await this._stripeAPIService.changeSubscriptionPlan(
subscription.id,
complimentaryPlan.id
);
await this.linkSubscription({
id: member.id,
subscription: updatedSubscription
});
}
}
}
async cancelComplimentarySubscription(data) {
if (!this._stripeAPIService) {
return;
}
const member = await this._Member.findOne({
id: data.id
});
const subscriptions = await member.related('stripeSubscriptions').fetch();
for (const subscription of subscriptions.models) {
if (subscription.get('status') !== 'canceled') {
try {
const updatedSubscription = await this._stripeAPIService.cancelSubscription(
subscription.get('subscription_id')
);
// Only needs to update `status`
await this.linkSubscription({
id: data.id,
subscription: updatedSubscription
});
} catch (err) {
this._logging.error(`There was an error cancelling subscription ${subscription.get('subscription_id')}`);
this._logging.error(err);
}
}
}
return true;
}
};

View file

@ -0,0 +1,15 @@
const got = require('got');
const IPV4_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
module.exports = class GeolocationService {
async getGeolocationFromIP(ipAddress) {
if (!ipAddress || (!IPV4_REGEX.test(ipAddress) && !IPV6_REGEX.test(ipAddress))) {
return;
}
const geojsUrl = `https://get.geojs.io/v1/ip/geo/${encodeURIComponent(ipAddress)}.json`;
const response = await got(geojsUrl, {json: true, timeout: 500});
return response.body;
}
};

View file

@ -0,0 +1,542 @@
const debug = require('ghost-ignition').debug('services/stripe');
const Stripe = require('stripe');
const LeakyBucket = require('leaky-bucket');
const EXPECTED_API_EFFICIENCY = 0.95;
/** @type {(data: string) => string} */
const hash = data => require('crypto').createHash('sha256').update(data).digest('hex');
const STRIPE_API_VERSION = '2019-09-09';
/**
* @typedef {import('stripe').IDataOptions} IDataOptions
* @typedef {import('stripe').customers.ICustomer} ICustomer
* @typedef {import('stripe').products.IProduct} IProduct
* @typedef {import('stripe').plans.IPlan} IPlan
* @typedef {import('stripe').webhookEndpoints.IWebhookEndpoint} IWebhookEndpoint
*/
/**
* @typedef {object} ILogger
* @prop {(x: any) => void} error
* @prop {(x: any) => void} info
* @prop {(x: any) => void} warn
*/
/**
* @typedef {'customers'|'subscriptions'|'plans'} StripeResource
*/
module.exports = class StripeAPIService {
/**
* StripeService
*
* @param {object} params
*
* @param {ILogger} params.logger
*
* @param {object} params.config
* @param {string} params.config.secretKey
* @param {string} params.config.publicKey
* @param {object} params.config.appInfo
* @param {string} params.config.appInfo.name
* @param {string} params.config.appInfo.version
* @param {string} params.config.appInfo.partner_id
* @param {string} params.config.appInfo.url
* @param {boolean} params.config.enablePromoCodes
*/
constructor({config, logger}) {
this.logging = logger;
if (config.secretKey) {
this.configure(config);
}
}
configure(config) {
this._stripe = new Stripe(config.secretKey);
this._config = config;
this._testMode = config.secretKey && config.secretKey.startsWith('sk_test_');
if (this._testMode) {
this._rateLimitBucket = new LeakyBucket(EXPECTED_API_EFFICIENCY * 25, 1);
} else {
this._rateLimitBucket = new LeakyBucket(EXPECTED_API_EFFICIENCY * 100, 1);
}
}
/**
* ensureProduct.
*
* @param {string} name
*
* @returns {Promise<IProduct>}
*/
async ensureProduct(name) {
const idSeed = 'Ghost Subscription';
/** @type {(x: string) => string} */
const prefixHashSeed = seed => (this._testMode ? `test_${seed}` : `prod_${seed}`);
/** @type {(idSeed: string) => Promise<IProduct>} */
const getOrCreateActiveProduct = async (idSeed) => {
const id = hash(prefixHashSeed(idSeed));
try {
await this._rateLimitBucket.throttle();
const product = await this._stripe.products.retrieve(id);
if (product.active) {
return product;
}
return getOrCreateActiveProduct(id);
} catch (err) {
if (err.code !== 'resource_missing') {
throw err;
}
await this._rateLimitBucket.throttle();
return this._stripe.products.create({
id,
name
});
}
};
return getOrCreateActiveProduct(idSeed);
}
/**
* ensurePlan.
*
* @param {object} plan
* @param {object} product
*
* @returns {Promise<IPlan>}
*/
async ensurePlan(plan, product) {
const idSeed = product.id + plan.interval + plan.currency + plan.amount;
/** @type {(x: string) => string} */
const prefixHashSeed = seed => (this._testMode ? `test_${seed}` : `prod_${seed}`);
/** @type {(idSeed: string) => Promise<IPlan>} */
const getOrCreateActivePlan = async (idSeed) => {
const id = hash(prefixHashSeed(idSeed));
try {
await this._rateLimitBucket.throttle();
const plan = await this._stripe.plans.retrieve(id);
if (plan.active) {
return plan;
}
return getOrCreateActivePlan(id);
} catch (err) {
if (err.code !== 'resource_missing') {
throw err;
}
await this._rateLimitBucket.throttle();
return this._stripe.plans.create({
id,
nickname: plan.name,
amount: plan.amount,
interval: plan.interval,
currency: plan.currency,
product: product.id,
billing_scheme: 'per_unit'
});
}
};
return getOrCreateActivePlan(idSeed);
}
/**
* @param {string} id
* @param {IDataOptions} options
*
* @returns {Promise<ICustomer>}
*/
async getCustomer(id, options = {}) {
debug(`getCustomer(${id}, ${JSON.stringify(options)})`);
try {
await this._rateLimitBucket.throttle();
const customer = await this._stripe.customers.retrieve(id, options);
debug(`getCustomer(${id}, ${JSON.stringify(options)}) -> Success`);
return customer;
} catch (err) {
debug(`getCustomer(${id}, ${JSON.stringify(options)}) -> ${err.type}`);
throw err;
}
}
/**
* @param {any} member
*
* @returns {Promise<ICustomer>}
*/
async getCustomerForMemberCheckoutSession(member) {
await member.related('stripeCustomers').fetch();
const customers = member.related('stripeCustomers');
for (const data of customers) {
try {
const customer = await this.getCustomer(data.customer_id);
if (!customer.deleted) {
return customer;
}
} catch (err) {
debug(`Ignoring Error getting customer for member ${err.message}`);
}
}
debug(`Creating customer for member ${member.get('email')}`);
const customer = await this.createCustomer({
email: member.get('email')
});
return customer;
}
/**
* @param {IDataOptions} options
*
* @returns {Promise<ICustomer>}
*/
async createCustomer(options = {}) {
debug(`createCustomer(${JSON.stringify(options)})`);
try {
await this._rateLimitBucket.throttle();
const customer = await this._stripe.customers.create(options);
debug(`createCustomer(${JSON.stringify(options)}) -> Success`);
return customer;
} catch (err) {
debug(`createCustomer(${JSON.stringify(options)}) -> ${err.type}`);
throw err;
}
}
/**
* @param {string} id
* @param {string} email
*
* @returns {Promise<ICustomer>}
*/
async updateCustomerEmail(id, email) {
debug(`updateCustomerEmail(${id}, ${email})`);
try {
await this._rateLimitBucket.throttle();
const customer = await this._stripe.customers.update(id, {email});
debug(`updateCustomerEmail(${id}, ${email}) -> Success`);
return customer;
} catch (err) {
debug(`updateCustomerEmail(${id}, ${email}) -> ${err.type}`);
throw err;
}
}
/**
* createWebhook.
*
* @param {string} url
* @param {import('stripe').events.EventType[]} events
*
* @returns {Promise<IWebhookEndpoint>}
*/
async createWebhookEndpoint(url, events) {
debug(`createWebhook(${url})`);
try {
await this._rateLimitBucket.throttle();
const webhook = await this._stripe.webhookEndpoints.create({
url,
enabled_events: events,
api_version: STRIPE_API_VERSION
});
debug(`createWebhook(${url}) -> Success`);
return webhook;
} catch (err) {
debug(`createWebhook(${url}) -> ${err.type}`);
throw err;
}
}
/**
* @param {string} id
*
* @returns {Promise<void>}
*/
async deleteWebhookEndpoint(id) {
debug(`deleteWebhook(${id})`);
try {
await this._rateLimitBucket.throttle();
await this._stripe.webhookEndpoints.del(id);
debug(`deleteWebhook(${id}) -> Success`);
return;
} catch (err) {
debug(`deleteWebhook(${id}) -> ${err.type}`);
throw err;
}
}
/**
* @param {string} id
* @param {string} url
* @param {import('stripe').events.EventType[]} events
*
* @returns {Promise<IWebhookEndpoint>}
*/
async updateWebhookEndpoint(id, url, events) {
debug(`updateWebhook(${id}, ${url})`);
try {
await this._rateLimitBucket.throttle();
const webhook = await this._stripe.webhookEndpoints.update(id, {
url,
enabled_events: events
});
if (webhook.api_version !== STRIPE_API_VERSION) {
throw new Error('Webhook has incorrect api_version');
}
debug(`updateWebhook(${id}, ${url}) -> Success`);
return webhook;
} catch (err) {
debug(`updateWebhook(${id}, ${url}) -> ${err.type}`);
throw err;
}
}
/**
* parseWebhook.
*
* @param {string} body
* @param {string} signature
* @param {string} secret
*
* @returns {import('stripe').events.IEvent}
*/
parseWebhook(body, signature, secret) {
debug(`parseWebhook(${body}, ${signature}, ${secret})`);
try {
const event = this._stripe.webhooks.constructEvent(body, signature, secret);
debug(`parseWebhook(${body}, ${signature}, ${secret}) -> Success ${event.type}`);
return event;
} catch (err) {
debug(`parseWebhook(${body}, ${signature}, ${secret}) -> ${err.type}`);
throw err;
}
}
/**
* @param {IPlan} plan
* @param {ICustomer} customer
* @param {object} options
*
* @returns {Promise<import('stripe').checkouts.sessions.ICheckoutSession>}
*/
async createCheckoutSession(plan, customer, options) {
const metadata = options.metadata || undefined;
const customerEmail = customer ? undefined : options.customerEmail;
await this._rateLimitBucket.throttle();
const session = await this._stripe.checkout.sessions.create({
payment_method_types: ['card'],
success_url: options.successUrl,
cancel_url: options.cancelUrl,
customer: customer ? customer.id : undefined,
customer_email: customerEmail,
// @ts-ignore - we need to update to latest stripe library to correctly use newer features
allow_promotion_codes: this._config.enablePromoCodes,
metadata,
subscription_data: {
trial_from_plan: true,
items: [{
plan: plan.id
}]
}
});
return session;
}
/**
* @param {ICustomer} customer
* @param {object} options
*
* @returns {Promise<import('stripe').checkouts.sessions.ICheckoutSession>}
*/
async createCheckoutSetupSession(customer, options) {
await this._rateLimitBucket.throttle();
const session = await this._stripe.checkout.sessions.create({
mode: 'setup',
payment_method_types: ['card'],
success_url: options.successUrl,
cancel_url: options.cancelUrl,
customer_email: customer.email,
setup_intent_data: {
metadata: {
customer_id: customer.id
}
}
});
return session;
}
getPublicKey() {
return this._config.publicKey;
}
/**
* getSubscription.
*
* @param {string} id
* @param {IDataOptions} options
*
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
*/
async getSubscription(id, options = {}) {
debug(`getSubscription(${id}, ${JSON.stringify(options)})`);
try {
await this._rateLimitBucket.throttle();
const subscription = await this._stripe.subscriptions.retrieve(id, options);
debug(`getSubscription(${id}, ${JSON.stringify(options)}) -> Success`);
return subscription;
} catch (err) {
debug(`getSubscription(${id}, ${JSON.stringify(options)}) -> ${err.type}`);
throw err;
}
}
/**
* cancelSubscription.
*
* @param {string} id
*
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
*/
async cancelSubscription(id) {
debug(`cancelSubscription(${id})`);
try {
await this._rateLimitBucket.throttle();
const subscription = await this._stripe.subscriptions.del(id);
debug(`cancelSubscription(${id}) -> Success`);
return subscription;
} catch (err) {
debug(`cancelSubscription(${id}) -> ${err.type}`);
throw err;
}
}
/**
* @param {string} id - The ID of the Subscription to modify
* @param {string} [reason=''] - The user defined cancellation reason
*
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
*/
async cancelSubscriptionAtPeriodEnd(id, reason = '') {
await this._rateLimitBucket.throttle();
const subscription = await this._stripe.subscriptions.update(id, {
cancel_at_period_end: true,
metadata: {
cancellation_reason: reason
}
});
return subscription;
}
/**
* @param {string} id - The ID of the Subscription to modify
*
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
*/
async continueSubscriptionAtPeriodEnd(id) {
await this._rateLimitBucket.throttle();
const subscription = await this._stripe.subscriptions.update(id, {
cancel_at_period_end: false,
metadata: {
cancellation_reason: null
}
});
return subscription;
}
/**
* @param {string} id - The ID of the Subscription to modify
* @param {string} plan - The ID of the new Plan
*
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
*/
async changeSubscriptionPlan(id, plan) {
await this._rateLimitBucket.throttle();
const subscription = await this._stripe.subscriptions.update(id, {
plan,
cancel_at_period_end: false,
metadata: {
cancellation_reason: null
}
});
return subscription;
}
/**
* @param {string} customer - The ID of the Customer to create the subscription for
* @param {string} plan - The ID of the new Plan
*
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
*/
async createSubscription(customer, plan) {
await this._rateLimitBucket.throttle();
const subscription = await this._stripe.subscriptions.create({
customer,
items: [{plan}]
});
return subscription;
}
/**
* @param {string} id
* @param {IDataOptions} options
*
* @returns {Promise<import('stripe').setupIntents.ISetupIntent>}
*/
async getSetupIntent(id, options = {}) {
await this._rateLimitBucket.throttle();
return await this._stripe.setupIntents.retrieve(id, options);
}
/**
* @param {string} customer
* @param {string} paymentMethod
*
* @returns {Promise<void>}
*/
async attachPaymentMethodToCustomer(customer, paymentMethod) {
await this._rateLimitBucket.throttle();
await this._stripe.paymentMethods.attach(paymentMethod, {customer});
return;
}
/**
* @param {string} id
*
* @returns {Promise<import('stripe').paymentMethods.ICardPaymentMethod|null>}
*/
async getCardPaymentMethod(id) {
await this._rateLimitBucket.throttle();
const paymentMethod = await this._stripe.paymentMethods.retrieve(id);
if (paymentMethod.type !== 'card') {
return null;
}
/** @type {import('stripe').paymentMethods.ICardPaymentMethod} */
return paymentMethod;
}
/**
* @param {string} subscription
* @param {string} paymentMethod
*
* @returns {Promise<import('stripe').subscriptions.ISubscription>}
*/
async updateSubscriptionDefaultPaymentMethod(subscription, paymentMethod) {
await this._rateLimitBucket.throttle();
return await this._stripe.subscriptions.update(subscription, {
default_payment_method: paymentMethod
});
}
};

View file

@ -0,0 +1,95 @@
/**
* @typedef {'usd'|'aud'|'cad'|'gbp'|'eur'|'inr'} Currency
*/
module.exports = class StripeService {
/**
* @param {object} deps
* @param {import('../stripe-api')} deps.stripeAPIService
*/
constructor({
stripeAPIService
}) {
this._stripeAPIService = stripeAPIService;
this._configured = false;
/** @type {import('stripe').products.IProduct} */
this._product = null;
/** @type {import('stripe').plans.IPlan[]} */
this._plans = null;
}
/**
* @returns {import('stripe').products.IProduct}
*/
getProduct() {
if (!this._configured) {
throw new Error('StripeService has not been configured');
}
return this._product;
}
/**
* @returns {import('stripe').plans.IPlan[]}
*/
getPlans() {
if (!this._configured) {
throw new Error('StripeService has not been configured');
}
return this._plans;
}
/**
* @param {string} nickname
* @returns {import('stripe').plans.IPlan}
*/
getPlan(nickname) {
if (!this._configured) {
throw new Error('StripeService has not been configured');
}
return this.getPlans().find((plan) => {
return plan.nickname.toLowerCase() === nickname.toLowerCase();
});
}
/**
* @param {Currency} currency
* @returns {import('stripe').plans.IPlan}
*/
getComplimentaryPlan(currency) {
if (!this._configured) {
throw new Error('StripeService has not been configured');
}
return this.getPlans().find((plan) => {
return plan.nickname.toLowerCase() === 'complimentary' && plan.currency === currency;
});
}
/**
* @param {object} config
* @param {object} config.product - The name for the product
* @param {string} config.product.name - The name for the product
*
* @param {object[]} config.plans
* @param {string} config.plans[].name
* @param {Currency} config.plans[].currency
* @param {'year'|'month'} config.plans[].interval
* @param {string} config.plans[].amount
*
* @returns {Promise<void>}
*/
async configure(config) {
try {
const product = await this._stripeAPIService.ensureProduct(config.product.name);
this._product = product;
this._plans = [];
for (const planSpec of config.plans) {
const plan = await this._stripeAPIService.ensurePlan(planSpec, product);
this._plans.push(plan);
}
this._configured = true;
} catch (err) {
console.log(err);
}
}
};

View file

@ -0,0 +1,225 @@
const _ = require('lodash');
module.exports = class StripeWebhookService {
/**
* @param {object} deps
* @param {any} deps.StripeWebhook
* @param {import('../stripe-api')} deps.stripeAPIService
* @param {import('../../repositories/member')} deps.memberRepository
* @param {any} deps.sendEmailWithMagicLink
*/
constructor({
StripeWebhook,
stripeAPIService,
memberRepository,
sendEmailWithMagicLink
}) {
this._StripeWebhook = StripeWebhook;
this._stripeAPIService = stripeAPIService;
this._memberRepository = memberRepository;
this._sendEmailWithMagicLink = sendEmailWithMagicLink;
this.handlers = {};
this.registerHandler('customer.subscription.deleted', this.subscriptionEvent);
this.registerHandler('customer.subscription.updated', this.subscriptionEvent);
this.registerHandler('customer.subscription.created', this.subscriptionEvent);
this.registerHandler('invoice.payment_succeeded', this.invoiceEvent);
this.registerHandler('invoice.payment_failed', this.invoiceEvent);
this.registerHandler('checkout.session.completed', this.checkoutSessionEvent);
}
registerHandler(event, handler) {
this.handlers[event] = handler.name;
}
async configure(config) {
if (config.webhookSecret) {
this._webhookSecret = config.webhookSecret;
return;
}
/** @type {import('stripe').events.EventType[]} */
const events = [
'checkout.session.completed',
'customer.subscription.deleted',
'customer.subscription.updated',
'customer.subscription.created',
'invoice.payment_succeeded',
'invoice.payment_failed'
];
const setupWebhook = async (id, secret, opts = {}) => {
if (!id || !secret || opts.forceCreate) {
if (id && !opts.skipDelete) {
try {
await this._stripeAPIService.deleteWebhookEndpoint(id);
} catch (err) {
// Continue
}
}
const webhook = await this._stripeAPIService.createWebhookEndpoint(
config.webhookHandlerUrl,
events
);
return {
id: webhook.id,
secret: webhook.secret
};
} else {
try {
await this._stripeAPIService.updateWebhookEndpoint(
id,
config.webhookHandlerUrl,
events
);
return {
id,
secret
};
} catch (err) {
if (err.code === 'resource_missing') {
return setupWebhook(id, secret, {skipDelete: true, forceCreate: true});
}
return setupWebhook(id, secret, {skipDelete: false, forceCreate: true});
}
}
};
const webhook = await setupWebhook(config.webhook.id, config.webhook.secret);
await this._StripeWebhook.upsert({
webhook_id: webhook.id,
secret: webhook.secret
}, {webhook_id: webhook.id});
this._webhookSecret = webhook.secret;
}
/**
* @param {string} body
* @param {string} signature
* @returns {import('stripe').events.IEvent}
*/
parseWebhook(body, signature) {
return this._stripeAPIService.parseWebhook(body, signature, this._webhookSecret);
}
/**
* @param {import('stripe').events.IEvent} event
*
* @returns {Promise<void>}
*/
async handleWebhook(event) {
if (!this.handlers[event.type]) {
return;
}
await this[this.handlers[event.type]](event.data.object);
}
async subscriptionEvent(subscription) {
const member = await this._memberRepository.get({
customer_id: subscription.customer
});
if (member) {
await this._memberRepository.linkSubscription({
id: member.id,
subscription
});
}
}
async invoiceEvent(invoice) {
const subscription = await this._stripeAPIService.getSubscription(invoice.subscription, {
expand: ['default_payment_method']
});
const member = await this._memberRepository.get({
customer_id: subscription.customer
});
if (member) {
await this._memberRepository.linkSubscription({
id: member.id,
subscription
});
}
}
async checkoutSessionEvent(session) {
if (session.mode === 'setup') {
const setupIntent = await this._stripeAPIService.getSetupIntent(session.setup_intent);
const member = await this._memberRepository.get({
customer_id: setupIntent.metadata.customer_id
});
await this._stripeAPIService.attachPaymentMethodToCustomer(
setupIntent.metadata.customer_id,
setupIntent.payment_method
);
const subscriptions = member.related('stripeSubscriptions').fetch();
for (const subscription of subscriptions.models) {
const updatedSubscription = await this._stripeAPIService.updateSubscriptionDefaultPaymentMethod(
subscription.id,
setupIntent.payment_method
);
await this._memberRepository.linkSubscription({
id: member.id,
subscription: updatedSubscription
});
}
}
if (session.mode === 'subscription') {
const customer = await this._stripeAPIService.getCustomer(session.customer, {
expand: ['subscriptions.data.default_payment_method']
});
let member = await this._memberRepository.get({
email: customer.email
});
const checkoutType = _.get(session, 'metadata.checkoutType');
const requestSrc = _.get(session, 'metadata.requestSrc') || '';
if (!member) {
const metadataName = _.get(session, 'metadata.name');
const payerName = _.get(customer, 'subscriptions.data[0].default_payment_method.billing_details.name');
const name = metadataName || payerName || null;
member = await this._memberRepository.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 this._memberRepository.update({name: payerName}, {id: member.get('id')});
}
}
await this._memberRepository.upsertCustomer({
customer_id: customer.id,
member_id: member.id,
name: customer.name,
email: customer.email
});
for (const subscription of customer.subscriptions.data) {
await this._memberRepository.linkSubscription({
id: member.id,
subscription
});
}
if (checkoutType !== 'upgrade') {
const emailType = 'signup';
this._sendEmailWithMagicLink({
email: customer.email,
requestedType: emailType,
requestSrc,
options: {forceEmailType: true},
tokenData: {}
});
}
}
}
};

View file

@ -0,0 +1,55 @@
const jose = require('node-jose');
const jwt = require('jsonwebtoken');
module.exports = class TokenService {
constructor({
privateKey,
publicKey,
issuer
}) {
this._keyStore = jose.JWK.createKeyStore();
this._keyStoreReady = this._keyStore.add(privateKey, 'pem');
this._privateKey = privateKey;
this._publicKey = publicKey;
this._issuer = issuer;
}
encodeAPIToken({sub, aud = this._issuer, plans, exp}) {
return this._keyStoreReady.then(jwk => jwt.sign({
sub,
plans,
kid: jwk.kid
}, this._privateKey, {
algorithm: 'RS512',
audience: aud,
expiresIn: exp,
issuer: this._issuer
}));
}
encodeIdentityToken({sub}) {
return this._keyStoreReady.then(jwk => jwt.sign({
sub,
kid: jwk.kid
}, this._privateKey, {
algorithm: 'RS512',
audience: this._issuer,
expiresIn: '10m',
issuer: this._issuer
}));
}
decodeToken(token) {
return this._keyStoreReady.then(jwk => jwt.verify(token, this._publicKey, {
algorithm: 'RS512',
kid: jwk.kid,
issuer: this._issuer
})).then(() => jwt.decode(token));
}
getPublicKeys() {
return this._keyStoreReady.then(() => {
this._keyStore.toJSON();
});
}
};

View file

@ -1,80 +0,0 @@
const hash = data => require('crypto').createHash('sha256').update(data).digest('hex');
const {
del: stripeDel,
create: stripeCreate,
retrieve: stripeRetrieve
} = require('./stripeRequests');
function createDeterministicApi(resource, validResult, getAttrs, generateHashSeed) {
const get = createGetter(resource, validResult);
const create = createCreator(resource, getAttrs);
const remove = createRemover(resource, get, generateHashSeed);
const ensure = createEnsurer(get, create, generateHashSeed);
return {
get, create, remove, ensure
};
}
function prefixHashSeed(stripe, seed) {
const prefix = stripe.__TEST_MODE__ ? 'test_' : 'prod_';
return prefix + seed;
}
function createGetter(resource, validResult) {
return function get(stripe, object, idSeed) {
const id = hash(prefixHashSeed(stripe, idSeed));
return stripeRetrieve(stripe, resource, id)
.then((result) => {
if (validResult(result)) {
return result;
}
return get(stripe, object, id);
}, (err) => {
err.id_requested = id;
throw err;
});
};
}
function createCreator(resource, getAttrs) {
return function create(stripe, id, object, ...rest) {
return stripeCreate(
stripe,
resource,
Object.assign(getAttrs(object, ...rest), {id})
).catch((err) => {
if (err.code !== 'resource_already_exists') {
throw err;
}
return stripeRetrieve(stripe, resource, id);
});
};
}
function createRemover(resource, get, generateHashSeed) {
return function remove(stripe, object, ...rest) {
return get(stripe, object, generateHashSeed(object, ...rest)).then((res) => {
return stripeDel(stripe, resource, res.id);
}).catch((err) => {
if (err.code !== 'resource_missing') {
throw err;
}
});
};
}
function createEnsurer(get, create, generateHashSeed) {
return function ensure(stripe, object, ...rest) {
return get(stripe, object, generateHashSeed(object, ...rest))
.catch((err) => {
if (err.code !== 'resource_missing') {
throw err;
}
const id = err.id_requested;
return create(stripe, id, object, ...rest);
});
};
}
module.exports = createDeterministicApi;

View file

@ -1,76 +0,0 @@
const debug = require('ghost-ignition').debug('stripe-request');
const LeakyBucket = require('leaky-bucket');
const EXPECTED_API_EFFICIENCY = 0.95;
const liveBucket = new LeakyBucket(EXPECTED_API_EFFICIENCY * 100, 1);
const testBucket = new LeakyBucket(EXPECTED_API_EFFICIENCY * 25, 1);
module.exports = function createStripeRequest(makeRequest) {
return async function stripeRequest(stripe, ...args) {
const throttledMakeRequest = async (stripe, ...args) => {
if (stripe.__TEST_MODE__) {
await testBucket.throttle();
} else {
await liveBucket.throttle();
}
return await makeRequest(stripe, ...args);
};
const errorHandler = (err) => {
switch (err.type) {
case 'StripeCardError':
// Card declined
debug('StripeCardError');
throw err;
case 'RateLimitError':
// Ronseal
debug('RateLimitError');
return exponentiallyBackoff(throttledMakeRequest, stripe, ...args).catch((err) => {
// We do not want to recurse further if we get RateLimitError
// after running the exponential backoff
if (err.type === 'RateLimitError') {
throw err;
}
return errorHandler(err);
});
case 'StripeInvalidRequestError':
debug('StripeInvalidRequestError');
// Invalid params to the request
throw err;
case 'StripeAPIError':
debug('StripeAPIError');
// Rare internal server error from stripe
throw err;
case 'StripeConnectionError':
debug('StripeConnectionError');
// Weird network/https issue
throw err;
case 'StripeAuthenticationError':
debug('StripeAuthenticationError');
// Invalid API Key (probably)
throw err;
default:
throw err;
}
};
return throttledMakeRequest(stripe, ...args).catch(errorHandler);
};
};
function exponentiallyBackoff(makeRequest, ...args) {
function backoffRequest(timeout, ...args) {
return new Promise(resolve => setTimeout(resolve, timeout)).then(() => {
return makeRequest(...args).catch((err) => {
if (err.type !== 'RateLimitError') {
throw err;
}
if (timeout > 30000) {
throw err;
}
return backoffRequest(timeout * 2, ...args);
});
});
}
return backoffRequest(1000, ...args);
}

View file

@ -1,4 +0,0 @@
module.exports = {
products: require('./products'),
plans: require('./plans')
};

View file

@ -1,21 +0,0 @@
const createDeterministicApi = require('./createDeterministicApi');
const isActive = x => x.active;
const getPlanAttr = ({name, amount, interval, currency}, product) => ({
nickname: name,
amount,
interval,
currency,
product: product.id,
billing_scheme: 'per_unit'
});
const getPlanHashSeed = (plan, product) => {
return product.id + plan.interval + plan.currency + plan.amount;
};
module.exports = createDeterministicApi(
'plans',
isActive,
getPlanAttr,
getPlanHashSeed
);

View file

@ -1,12 +0,0 @@
const createDeterministicApi = require('./createDeterministicApi');
const isActive = x => x.active;
const getProductAttr = ({name}) => ({name, type: 'service'});
const getProductHashSeed = () => 'Ghost Subscription';
module.exports = createDeterministicApi(
'products',
isActive,
getProductAttr,
getProductHashSeed
);

View file

@ -1,35 +0,0 @@
const debug = require('ghost-ignition').debug('stripe-request');
const createStripeRequest = require('./createStripeRequest');
const retrieve = createStripeRequest(function (stripe, resource, id, options = {}) {
debug(`retrieve ${resource} ${id}`);
return stripe[resource].retrieve(id, options);
});
const list = createStripeRequest(function (stripe, resource, options) {
debug(`list ${resource} ${JSON.stringify(options)}`);
return stripe[resource].list(options);
});
const create = createStripeRequest(function (stripe, resource, object) {
debug(`create ${resource} ${JSON.stringify(object)}`);
return stripe[resource].create(object);
});
const update = createStripeRequest(function (stripe, resource, id, object) {
debug(`update ${resource} ${id} ${JSON.stringify(object)}`);
return stripe[resource].update(id, object);
});
const del = createStripeRequest(function (stripe, resource, id) {
debug(`delete ${resource} ${id}`);
return stripe[resource].del(id);
});
module.exports = {
retrieve,
list,
create,
update,
del
};

View file

@ -1,517 +0,0 @@
const debug = require('ghost-ignition').debug('stripe');
const _ = require('lodash');
const {retrieve, create, update, del} = require('./api/stripeRequests');
const api = require('./api');
const STRIPE_API_VERSION = '2019-09-09';
module.exports = class StripePaymentProcessor {
constructor(config, storage, logging) {
this.logging = logging;
this.storage = storage;
this._ready = new Promise((resolve, reject) => {
this._resolveReady = resolve;
this._rejectReady = reject;
});
/**
* @type Array<import('stripe').plans.IPlan>
*/
this._plans = [];
this._configure(config);
}
async ready() {
return this._ready;
}
async _configure(config) {
this._stripe = require('stripe')(config.secretKey);
this._stripe.setAppInfo(config.appInfo);
this._stripe.setApiVersion(STRIPE_API_VERSION);
this._stripe.__TEST_MODE__ = config.secretKey.startsWith('sk_test_');
this._public_token = config.publicKey;
this._checkoutSuccessUrl = config.checkoutSuccessUrl;
this._checkoutCancelUrl = config.checkoutCancelUrl;
this._billingSuccessUrl = config.billingSuccessUrl;
this._billingCancelUrl = config.billingCancelUrl;
this._enablePromoCodes = config.enablePromoCodes;
try {
this._product = await api.products.ensure(this._stripe, config.product);
} catch (err) {
this.logging.error('There was an error creating the Stripe Product');
this.logging.error(err);
return this._rejectReady(err);
}
for (const planSpec of config.plans) {
try {
const plan = await api.plans.ensure(this._stripe, planSpec, this._product);
this._plans.push(plan);
} catch (err) {
this.logging.error('There was an error creating the Stripe Plan');
this.logging.error(err);
return this._rejectReady(err);
}
}
if (process.env.WEBHOOK_SECRET) {
this.logging.warn(`Skipping Stripe webhook creation and validation, using WEBHOOK_SECRET environment variable`);
this._webhookSecret = process.env.WEBHOOK_SECRET;
return this._resolveReady({
product: this._product,
plans: this._plans
});
}
const webhookConfig = {
url: config.webhookHandlerUrl,
enabled_events: [
'checkout.session.completed',
'customer.subscription.deleted',
'customer.subscription.updated',
'customer.subscription.created',
'invoice.payment_succeeded',
'invoice.payment_failed'
]
};
const setupWebhook = async (id, secret, opts = {}) => {
if (!id || !secret || opts.forceCreate) {
if (id && !opts.skipDelete) {
try {
this.logging.info(`Deleting Stripe webhook ${id}`);
await del(this._stripe, 'webhookEndpoints', id);
} catch (err) {
this.logging.error(`Unable to delete Stripe webhook with id: ${id}`);
this.logging.error(err);
}
}
try {
this.logging.info(`Creating Stripe webhook with url: ${webhookConfig.url}, version: ${STRIPE_API_VERSION}, events: ${webhookConfig.enabled_events.join(', ')}`);
const webhook = await create(this._stripe, 'webhookEndpoints', Object.assign({}, webhookConfig, {
api_version: STRIPE_API_VERSION
}));
return {
id: webhook.id,
secret: webhook.secret
};
} catch (err) {
this.logging.error('Failed to create Stripe webhook. For local development please see https://ghost.org/docs/members/webhooks/#stripe-webhooks');
this.logging.error(err);
throw err;
}
} else {
try {
this.logging.info(`Updating Stripe webhook ${id} with url: ${webhookConfig.url}, events: ${webhookConfig.enabled_events.join(', ')}`);
const updatedWebhook = await update(this._stripe, 'webhookEndpoints', id, webhookConfig);
if (updatedWebhook.api_version !== STRIPE_API_VERSION) {
throw new Error(`Webhook ${id} has api_version ${updatedWebhook.api_version}, expected ${STRIPE_API_VERSION}`);
}
return {
id,
secret
};
} catch (err) {
this.logging.error(`Unable to update Stripe webhook ${id}`);
this.logging.error(err);
if (err.code === 'resource_missing') {
return setupWebhook(id, secret, {skipDelete: true, forceCreate: true});
}
return setupWebhook(id, secret, {skipDelete: false, forceCreate: true});
}
}
};
try {
const webhook = await setupWebhook(config.webhook.id, config.webhook.secret);
await this.storage.set({
webhook: {
webhook_id: webhook.id,
secret: webhook.secret
}
});
this._webhookSecret = webhook.secret;
} catch (err) {
return this._rejectReady(err);
}
return this._resolveReady({
product: this._product,
plans: this._plans
});
}
async parseWebhook(body, signature) {
try {
const event = await this._stripe.webhooks.constructEvent(body, signature, this._webhookSecret);
debug(`Parsed webhook event: ${event.type}`);
return event;
} catch (err) {
this.logging.error(`Error verifying webhook signature, using secret ${this._webhookSecret}`);
throw err;
}
}
async createCheckoutSession(member, planName, options) {
let customer;
if (member) {
try {
customer = await this._customerForMemberCheckoutSession(member);
} catch (err) {
debug(`Ignoring Error getting customer for checkout ${err.message}`);
customer = null;
}
} else {
customer = null;
}
const plan = this._plans.find(plan => plan.nickname === planName);
const customerEmail = (!customer && options.customerEmail) ? options.customerEmail : undefined;
const metadata = options.metadata || undefined;
const session = await this._stripe.checkout.sessions.create({
payment_method_types: ['card'],
success_url: options.successUrl || this._checkoutSuccessUrl,
cancel_url: options.cancelUrl || this._checkoutCancelUrl,
customer: customer ? customer.id : undefined,
customer_email: customerEmail,
allow_promotion_codes: this._enablePromoCodes,
metadata,
subscription_data: {
trial_from_plan: true,
items: [{
plan: plan.id
}]
}
});
return {
sessionId: session.id,
publicKey: this._public_token
};
}
async linkStripeCustomer(id, member, options) {
const customer = await retrieve(this._stripe, 'customers', id);
await this._updateCustomer(member, customer, options);
debug(`Linking customer:${id} subscriptions`, JSON.stringify(customer.subscriptions));
if (customer.subscriptions && customer.subscriptions.data) {
for (const subscription of customer.subscriptions.data) {
await this._updateSubscription(subscription, options);
}
}
return customer;
}
async createCheckoutSetupSession(member, options) {
const customer = await this._customerForMemberCheckoutSession(member);
const session = await this._stripe.checkout.sessions.create({
mode: 'setup',
payment_method_types: ['card'],
success_url: options.successUrl || this._billingSuccessUrl,
cancel_url: options.cancelUrl || this._billingCancelUrl,
customer_email: member.get('email'),
setup_intent_data: {
metadata: {
customer_id: customer.id
}
}
});
return {
sessionId: session.id,
publicKey: this._public_token
};
}
async cancelAllSubscriptions(member) {
const subscriptions = await this.getSubscriptions(member);
const activeSubscriptions = subscriptions.filter((subscription) => {
return subscription.status !== 'canceled';
});
for (const subscription of activeSubscriptions) {
try {
const updatedSubscription = await del(this._stripe, 'subscriptions', subscription.id);
await this._updateSubscription(updatedSubscription);
} catch (err) {
this.logging.error(`There was an error cancelling subscription ${subscription.id}`);
this.logging.error(err);
}
}
return true;
}
async updateSubscriptionFromClient(subscription) {
/** @type {Object} */
const data = _.pick(subscription, ['plan', 'cancel_at_period_end']);
data.metadata = {
cancellation_reason: subscription.cancellation_reason || null
};
const updatedSubscription = await update(this._stripe, 'subscriptions', subscription.id, data);
await this._updateSubscription(updatedSubscription);
return updatedSubscription;
}
findPlanByNickname(nickname) {
return this._plans.find(plan => plan.nickname === nickname);
}
async getSubscriptions(member) {
const metadata = await this.storage.get(member);
return metadata.subscriptions;
}
async createComplimentarySubscription(customer) {
const monthlyPlan = this._plans.find(plan => plan.interval === 'month');
if (!monthlyPlan) {
throw new Error('Could not find monthly plan');
}
const complimentaryCurrency = monthlyPlan.currency.toLowerCase();
const complimentaryPlan = this._plans.find(plan => plan.nickname === 'Complimentary' && plan.currency === complimentaryCurrency);
if (!complimentaryPlan) {
throw new Error('Could not find complimentaryPlan');
}
return create(this._stripe, 'subscriptions', {
customer: customer.id,
items: [{
plan: complimentaryPlan.id
}]
});
}
async setComplimentarySubscription(member, options) {
const subscriptions = await this.getActiveSubscriptions(member, options);
// NOTE: Because we allow for multiple Complimentary plans, need to take into account currently availalbe
// plan currencies so that we don't end up giving a member complimentary subscription in wrong currency.
// Giving member a subscription in different currency would prevent them from resubscribing with a regular
// plan if Complimentary is cancelled (ref. https://stripe.com/docs/billing/customer#currency)
let complimentaryCurrency = this._plans.find(plan => plan.interval === 'month').currency.toLowerCase();
if (subscriptions.length) {
complimentaryCurrency = subscriptions[0].plan.currency.toLowerCase();
}
const complimentaryFilter = plan => (plan.nickname === 'Complimentary' && plan.currency === complimentaryCurrency);
const complimentaryPlan = this._plans.find(complimentaryFilter);
if (!complimentaryPlan) {
throw new Error('Could not find Complimentary plan');
}
const customer = await this._customerForMemberCheckoutSession(member, options);
if (!subscriptions.length) {
const subscription = await create(this._stripe, 'subscriptions', {
customer: customer.id,
items: [{
plan: complimentaryPlan.id
}]
});
await this._updateSubscription(subscription, options);
} else {
// NOTE: we should only ever have 1 active subscription, but just in case there is more update is done on all of them
for (const subscription of subscriptions) {
const updatedSubscription = await update(this._stripe, 'subscriptions', subscription.id, {
proration_behavior: 'none',
plan: complimentaryPlan.id
});
await this._updateSubscription(updatedSubscription, options);
}
}
}
async cancelComplimentarySubscription(member) {
// NOTE: a more explicit way would be cancelling just the "Complimentary" subscription, but doing it
// through existing method achieves the same as there should be only one subscription at a time
await this.cancelAllSubscriptions(member);
}
async getActiveSubscriptions(member) {
const subscriptions = await this.getSubscriptions(member);
return subscriptions.filter((subscription) => {
return ['active', 'trialing', 'unpaid', 'past_due'].includes(subscription.status);
});
}
async handleCheckoutSessionCompletedWebhook(member, customer) {
await this._updateCustomer(member, customer);
if (!customer.subscriptions || !customer.subscriptions.data) {
return;
}
for (const subscription of customer.subscriptions.data) {
await this._updateSubscription(subscription);
}
}
async handleCheckoutSetupSessionCompletedWebhook(setupIntent, member) {
const customerId = setupIntent.metadata.customer_id;
const paymentMethod = setupIntent.payment_method;
// NOTE: has to attach payment method before being able to use it as default in the future
await this._stripe.paymentMethods.attach(paymentMethod, {
customer: customerId
});
const customer = await this.getCustomer(customerId);
await this._updateCustomer(member, customer);
if (!customer.subscriptions || !customer.subscriptions.data) {
return;
}
for (const subscription of customer.subscriptions.data) {
const updatedSubscription = await update(this._stripe, 'subscriptions', subscription.id, {
default_payment_method: paymentMethod
});
await this._updateSubscription(updatedSubscription);
}
}
async handleCustomerSubscriptionDeletedWebhook(subscription) {
await this._updateSubscription(subscription);
}
async handleCustomerSubscriptionUpdatedWebhook(subscription) {
await this._updateSubscription(subscription);
}
async handleCustomerSubscriptionCreatedWebhook(subscription) {
await this._updateSubscription(subscription);
}
async handleInvoicePaymentSucceededWebhook(invoice) {
const subscription = await retrieve(this._stripe, 'subscriptions', invoice.subscription, {
expand: ['default_payment_method']
});
await this._updateSubscription(subscription);
}
async handleInvoicePaymentFailedWebhook(invoice) {
const subscription = await retrieve(this._stripe, 'subscriptions', invoice.subscription, {
expand: ['default_payment_method']
});
await this._updateSubscription(subscription);
}
/**
* @param {string} customerId - The ID of the Stripe Customer to update
* @param {string} email - The email to update
*
* @returns {Promise<null>}
*/
async updateStripeCustomerEmail(customerId, email) {
try {
await update(this._stripe, 'customers', customerId, {
email
});
} catch (err) {
this.logging.error(err, {
message: 'Failed to update Stripe Customer email'
});
}
return null;
}
async _updateCustomer(member, customer, options = {}) {
debug(`Attaching customer to member ${member.get('email')} ${customer.id}`);
await this.storage.set({
customer: {
customer_id: customer.id,
member_id: member.get('id'),
name: customer.name,
email: customer.email
}
}, options);
}
async _updateSubscription(subscription, options) {
const payment = subscription.default_payment_method;
if (typeof payment === 'string') {
debug(`Fetching default_payment_method for subscription ${subscription.id}`);
const subscriptionWithPayment = await retrieve(this._stripe, 'subscriptions', subscription.id, {
expand: ['default_payment_method']
});
return this._updateSubscription(subscriptionWithPayment, options);
}
const mappedSubscription = {
customer_id: subscription.customer,
subscription_id: subscription.id,
status: subscription.status,
cancel_at_period_end: subscription.cancel_at_period_end,
cancellation_reason: subscription.metadata && subscription.metadata.cancellation_reason || null,
current_period_end: new Date(subscription.current_period_end * 1000),
start_date: new Date(subscription.start_date * 1000),
default_payment_card_last4: payment && payment.card && payment.card.last4 || null,
plan_id: subscription.plan.id,
// NOTE: Defaulting to interval as migration to nullable field turned out to be much bigger problem.
// Ideally, would need nickname field to be nullable on the DB level - condition can be simplified once this is done
plan_nickname: subscription.plan.nickname || subscription.plan.interval,
plan_interval: subscription.plan.interval,
plan_amount: subscription.plan.amount,
plan_currency: subscription.plan.currency
};
debug(`Attaching subscription to customer ${subscription.customer} ${subscription.id}`);
debug(`Subscription details`, JSON.stringify(mappedSubscription));
await this.storage.set({
subscription: mappedSubscription
}, options);
}
async _customerForMemberCheckoutSession(member, options) {
const metadata = await this.storage.get(member, options);
for (const data of metadata.customers) {
try {
const customer = await this.getCustomer(data.customer_id);
if (!customer.deleted) {
return customer;
}
} catch (err) {
debug(`Ignoring Error getting customer for member ${err.message}`);
}
}
debug(`Creating customer for member ${member.get('email')}`);
const customer = await this.createCustomer({
email: member.get('email')
});
await this._updateCustomer(member, customer, options);
return customer;
}
async getSetupIntent(id, options = {}) {
return retrieve(this._stripe, 'setupIntents', id, options);
}
async createCustomer(options) {
return create(this._stripe, 'customers', options);
}
async getCustomer(id, options = {}) {
return retrieve(this._stripe, 'customers', id, options);
}
};

View file

@ -1,57 +0,0 @@
const jose = require('node-jose');
const jwt = require('jsonwebtoken');
module.exports = function ({
privateKey,
publicKey,
issuer
}) {
const keyStore = jose.JWK.createKeyStore();
const keyStoreReady = keyStore.add(privateKey, 'pem');
function encodeAPIToken({sub, aud = issuer, plans, exp}) {
return keyStoreReady.then(jwk => jwt.sign({
sub,
plans,
kid: jwk.kid
}, privateKey, {
algorithm: 'RS512',
audience: aud,
expiresIn: exp,
issuer
}));
}
function encodeIdentityToken({sub}) {
return keyStoreReady.then(jwk => jwt.sign({
sub,
kid: jwk.kid
}, privateKey, {
algorithm: 'RS512',
audience: issuer,
expiresIn: '10m',
issuer
}));
}
function decodeToken(token) {
return keyStoreReady.then(jwk => jwt.verify(token, publicKey, {
algorithm: 'RS512',
kid: jwk.kid,
issuer
})).then(() => jwt.decode(token));
}
function getPublicKeys() {
return keyStoreReady.then(() => {
keyStore.toJSON();
});
}
return {
encodeAPIToken,
encodeIdentityToken,
decodeToken,
getPublicKeys
};
};

View file

@ -1,174 +0,0 @@
const _ = require('lodash');
const debug = require('ghost-ignition').debug('users');
const common = require('../lib/common');
module.exports = function ({
stripe,
Member,
StripeCustomer
}) {
async function get(data, options) {
debug(`get id:${data.id} email:${data.email}`);
return Member.findOne(data, options);
}
async function destroy(data, options) {
debug(`destroy id:${data.id} email:${data.email}`);
const member = await Member.findOne(data, options);
if (!member) {
return;
}
if (stripe && options.cancelStripeSubscriptions) {
await stripe.cancelAllSubscriptions(member);
}
return Member.destroy({
id: data.id
}, options);
}
async function update(data, options) {
debug(`update id:${options.id}`);
const member = await Member.edit(_.pick(data, [
'email',
'name',
'note',
'subscribed',
'labels',
'geolocation'
]), options);
if (member._changed.email) {
await member.related('stripeCustomers').fetch();
const customers = member.related('stripeCustomers');
for (const customer of customers.models) {
await stripe.updateStripeCustomerEmail(customer.get('customer_id'), member.get('email'));
}
}
return member;
}
async function list(options = {}) {
return Member.findPage(options);
}
async function create(data, options) {
const {email, labels} = data;
debug(`create email:${email}`);
/** Member.add model method expects label object array*/
if (labels) {
labels.forEach((label, index) => {
if (_.isString(label)) {
labels[index] = {name: label};
}
});
}
return Member.add(Object.assign(
{},
{labels},
_.pick(data, [
'email',
'name',
'note',
'subscribed',
'geolocation',
'created_at'
])), options);
}
function safeStripe(methodName) {
return async function (...args) {
if (stripe) {
return await stripe[methodName](...args);
}
};
}
async function linkStripeCustomerById(customerId, memberId) {
if (!stripe) {
return;
}
const member = await get({id: memberId});
return stripe.linkStripeCustomer(customerId, member);
}
async function setComplimentarySubscriptionById(memberId) {
if (!stripe) {
return;
}
const member = await get({id: memberId});
return stripe.setComplimentarySubscription(member);
}
async function updateSubscription(memberId, {cancelAtPeriodEnd, subscriptionId}) {
// Don't allow removing subscriptions that don't belong to the member
const member = await get({id: memberId});
const subscriptions = await stripe.getSubscriptions(member);
const subscription = subscriptions.find(sub => sub.id === subscriptionId);
if (!subscription) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed! Could not find subscription'
});
}
if (cancelAtPeriodEnd === undefined) {
throw new common.errors.BadRequestError({
message: 'Updating subscription failed!',
help: 'Request should contain "cancel" field.'
});
}
const subscriptionUpdate = {
id: subscription.id,
cancel_at_period_end: !!(cancelAtPeriodEnd)
};
await stripe.updateSubscriptionFromClient(subscriptionUpdate);
}
async function linkStripeCustomer(id, member, options) {
if (!stripe) {
throw new common.errors.BadRequestError({
message: 'Cannot link Stripe Customer without a Stripe connection'
});
}
const existingCustomer = await StripeCustomer.findOne({customer_id: id}, options);
if (existingCustomer) {
throw new common.errors.BadRequestError({
message: 'Cannot link Stripe Customer already associated with a member'
});
}
return stripe.linkStripeCustomer(id, member, options);
}
async function setComplimentarySubscription(member, options) {
if (!stripe) {
throw new common.errors.BadRequestError({
message: 'Cannot link create Complimentary Subscription without a Stripe connection'
});
}
return stripe.setComplimentarySubscription(member, options);
}
return {
create,
update,
list,
get,
destroy,
updateSubscription,
setComplimentarySubscription,
setComplimentarySubscriptionById,
cancelComplimentarySubscription: safeStripe('cancelComplimentarySubscription'),
cancelStripeSubscriptions: safeStripe('cancelComplimentarySubscription'),
getStripeCustomer: safeStripe('getCustomer'),
createStripeCustomer: safeStripe('createCustomer'),
createComplimentarySubscription: safeStripe('createComplimentarySubscription'),
linkStripeCustomer,
linkStripeCustomerById
};
};

View file

@ -1,5 +1,5 @@
const nock = require('nock');
const {getGeolocationFromIP} = require('../../../lib/geolocation');
const GeolocationService = require('../../../lib/services/geolocation');
const RESPONSE = {
longitude: '-2.2417',
@ -19,6 +19,8 @@ const RESPONSE = {
country_code3: 'GBR'
};
const service = new GeolocationService();
describe('lib/geolocation', function () {
describe('getGeolocationFromIP', function () {
afterEach(function () {
@ -31,7 +33,7 @@ describe('lib/geolocation', function () {
.get('/v1/ip/geo/188.39.113.90.json')
.reply(200, RESPONSE);
const result = await getGeolocationFromIP('188.39.113.90');
const result = await service.getGeolocationFromIP('188.39.113.90');
scope.isDone().should.eql(true, 'request was not made');
should.exist(result, 'nothing was returned');
@ -43,7 +45,7 @@ describe('lib/geolocation', function () {
.get('/v1/ip/geo/2a01%3A4c8%3A43a%3A13c9%3A8d6%3A128e%3A1fd5%3A6aad.json')
.reply(200, RESPONSE);
const result = await getGeolocationFromIP('2a01:4c8:43a:13c9:8d6:128e:1fd5:6aad');
const result = await service.getGeolocationFromIP('2a01:4c8:43a:13c9:8d6:128e:1fd5:6aad');
scope.isDone().should.eql(true, 'request was not made');
should.exist(result, 'nothing was returned');
@ -52,22 +54,22 @@ describe('lib/geolocation', function () {
it('handles non-IP addresses', async function () {
let scope = nock('https://get.geojs.io').get('/v1/ip/geo/.json').reply(200, {test: true});
let result = await getGeolocationFromIP('');
let result = await service.getGeolocationFromIP('');
scope.isDone().should.eql(false);
should.equal(undefined, result);
scope = nock('https://get.geojs.io').get('/v1/ip/geo/null.json').reply(200, {test: true});
result = await getGeolocationFromIP(null);
result = await service.getGeolocationFromIP(null);
scope.isDone().should.eql(false);
should.equal(undefined, result);
scope = nock('https://get.geojs.io').get('/v1/ip/geo/undefined.json').reply(200, {test: true});
result = await getGeolocationFromIP(undefined);
result = await service.getGeolocationFromIP(undefined);
scope.isDone().should.eql(false);
should.equal(undefined, result);
scope = nock('https://get.geojs.io').get('/v1/ip/geo/test.json').reply(200, {test: true});
result = await getGeolocationFromIP('test');
result = await service.getGeolocationFromIP('test');
scope.isDone().should.eql(false);
should.equal(undefined, result);
});

View file

@ -1,69 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
"allowJs": true, /* Allow javascript files to be compiled. */
"checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
// "outDir": "./", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
"removeComments": true, /* Do not emit comments to output. */
"noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}