Added initial subscription support with stripe to Members API (#10460)

These changes introduce a new "service" to the members api, which handles getting and creating subscriptions.

This is wired up to get subscription information when creating tokens, and attaching information to the token, so that the Content API can allow/deny access. 

Behind the subscription service we have a Stripe "payment processor", this holds the logic for creating subscriptions etc... in Stripe.

The logic for getting items out of stripe uses a hash of the relevant data as the id to search for, this allows us to forgo keeping stripe data in a db, so that this feature can get out quicker.
This commit is contained in:
Fabien O'Carroll 2019-02-07 10:41:39 +01:00 committed by GitHub
parent 5f66026647
commit 46bf5270df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 363 additions and 10 deletions

View File

@ -19,6 +19,9 @@
"scheduling": {
"active": "SchedulingDefault"
},
"members": {
"paymentProcessors": []
},
"logging": {
"level": "info",
"rotation": {

View File

@ -23,7 +23,7 @@ module.exports = function cookies(sessionSecret) {
function setCookie(member) {
return cookie.serialize('signedin', member.id, {
maxAge: MAX_AGE,
path: '/ghost/api/v2/members/token',
path: '/ghost/api/v2/members',
httpOnly: true,
encode: encodeCookie
});
@ -32,7 +32,7 @@ module.exports = function cookies(sessionSecret) {
function removeCookie() {
return cookie.serialize('signedin', false, {
maxAge: 0,
path: '/ghost/api/v2/members/token',
path: '/ghost/api/v2/members',
httpOnly: true
});
}

View File

@ -6,15 +6,17 @@ const {getData, handleError} = require('./util');
const Cookies = require('./cookies');
const Tokens = require('./tokens');
const Users = require('./users');
const Subscriptions = require('./subscriptions');
module.exports = function MembersApi({
config: {
authConfig: {
issuer,
privateKey,
publicKey,
sessionSecret,
ssoOrigin
},
paymentConfig,
validateAudience,
createMember,
validateMember,
@ -25,7 +27,10 @@ module.exports = function MembersApi({
}) {
const {encodeToken, decodeToken, getPublicKeys} = Tokens({privateKey, publicKey, issuer});
const subscriptions = new Subscriptions(paymentConfig);
const users = Users({
subscriptions,
createMember,
updateMember,
getMember,
@ -58,8 +63,12 @@ module.exports = function MembersApi({
const {audience, origin} = req.data;
validateAudience({audience, origin, id: signedin})
.then(() => encodeToken({
sub: signedin,
.then(() => {
return users.get({id: signedin});
})
.then(member => encodeToken({
sub: member.id,
plans: member.subscriptions.map(sub => sub.plan),
aud: audience
}))
.then(token => res.end(token))
@ -75,6 +84,38 @@ module.exports = function MembersApi({
next();
}
/* subscriptions */
apiRouter.post('/subscription', getData('adapter', 'plan', 'stripeToken'), ssoOriginCheck, (req, res) => {
const {signedin} = getCookie(req);
if (!signedin) {
res.writeHead(401, {
'Set-Cookie': removeCookie()
});
return res.end();
}
const {plan, adapter, stripeToken} = req.data;
subscriptions.getAdapters()
.then((adapters) => {
if (!adapters.includes(adapter)) {
throw new Error('Invalid adapter');
}
})
.then(() => users.get({id: signedin}))
.then((member) => {
return subscriptions.createSubscription(member, {
adapter,
plan,
stripeToken
});
})
.then(() => {
res.end();
})
.catch(handleError(500, res));
});
/* users, token, emails */
apiRouter.post('/request-password-reset', getData('email'), ssoOriginCheck, (req, res) => {
const {email} = req.data;

View File

@ -0,0 +1,59 @@
const stripe = require('./payment-processors/stripe');
const adapters = {
stripe
};
module.exports = class PaymentProcessorService {
constructor(config) {
this._ready = new Promise(() => {});
process.nextTick(() => this.configure(config));
}
configure({processors}) {
this._processors = {};
this._ready = Promise.all(processors.map(({
adapter,
config
}) => {
this._processors[adapter] = new adapters[adapter];
return this._processors[adapter].configure(config);
})).then(() => {
return Object.keys(this._processors);
});
return this._ready;
}
getAdapters() {
return this._ready;
}
getConfig(adapter) {
if (!adapter) {
return Promise.reject(new Error('getConfig(adapter) requires an adapter'));
}
return this._ready.then(() => {
return this._processors[adapter].getConfig();
});
}
createSubscription(member, metadata) {
if (!metadata.adapter) {
return Promise.reject(new Error('createSubscription(member, { adapter }) requires an adapter'));
}
return this._ready.then(() => {
return this._processors[metadata.adapter].createSubscription(member, metadata);
});
}
getSubscription(member, metadata) {
if (!metadata.adapter) {
return Promise.reject(new Error('getSubscription(member, { adapter }) requires an adapter'));
}
return this._ready.then(() => {
return this._processors[metadata.adapter].getSubscription(member, metadata);
});
}
};

View File

@ -0,0 +1,142 @@
const hash = data => require('crypto').createHash('sha256').update(data).digest('hex');
const isActive = x => x.active;
const isNotDeleted = x => !x.deleted;
const getPlanAttr = ({name, amount, interval, currency}, product) => ({
nickname: name,
amount,
interval,
currency,
product: product.id,
billing_scheme: 'per_unit'
});
const getProductAttr = ({name}) => ({name, type: 'service'});
const getCustomerAttr = ({email}) => ({email});
const getPlanHashSeed = (plan, product) => {
return product.id + plan.interval + plan.currency + plan.amount;
};
const getProductHashSeed = product => product.name;
const getCustomerHashSeed = member => member.email;
const plans = createApi('plans', isActive, getPlanAttr, getPlanHashSeed);
const products = createApi('products', isActive, getProductAttr, getProductHashSeed);
const customers = createApi('customers', isNotDeleted, getCustomerAttr, getCustomerHashSeed);
function removeSubscription(stripe, member) {
return customers.get(stripe, member, member.email).then((customer) => {
// CASE customer has no subscriptions
if (!customer.subscriptions || customer.subscriptions.total_count === 0) {
throw new Error('Cannot remove subscription');
}
const subscription = customer.subscriptions.data[0];
return stripe.subscriptions.del(subscription.id);
});
}
function getSubscription(stripe, member) {
return customers.get(stripe, member, member.email).then((customer) => {
// CASE customer has either none or multiple subscriptions
if (!customer.subscriptions || customer.subscriptions.total_count !== 1) {
return {};
}
const subscription = customer.subscriptions.data[0];
// CASE subscription has multiple plans
if (subscription.items.total_count !== 1) {
return {};
}
const plan = subscription.plan;
return {
validUntil: subscription.current_period_end,
plan: plan.nickname,
status: subscription.status
};
}).catch(() => {
return {};
});
}
function createSubscription(stripe, member, metadata) {
return customers.ensure(stripe, member, member.email).then((customer) => {
if (customer.subscriptions && customer.subscriptions.total_count !== 0) {
throw new Error('Customer already has a subscription');
}
return stripe.customers.createSource(customer.id, {
source: metadata.stripeToken
}).then(() => {
return stripe.subscriptions.create({
customer: customer.id,
items: [{plan: metadata.plan.id}]
});
});
});
}
const subscriptions = {
create: createSubscription,
get: getSubscription,
remove: removeSubscription
};
module.exports = {
plans,
products,
customers,
subscriptions
};
function createGetter(resource, validResult) {
return function get(stripe, object, idSeed) {
const id = hash(idSeed);
return stripe[resource].retrieve(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 stripe[resource].create(
Object.assign(getAttrs(object, ...rest), {id})
);
};
}
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);
});
};
}
function createApi(resource, validResult, getAttrs, generateHashSeed) {
const get = createGetter(resource, validResult);
const create = createCreator(resource, getAttrs);
const ensure = createEnsurer(get, create, generateHashSeed);
return {
get, create, ensure
};
}

View File

@ -0,0 +1,74 @@
const api = require('./api');
module.exports = class StripePaymentProcessor {
constructor() {
this._ready = new Promise(() => {});
}
configure(config) {
const stripe = require('stripe')(config.secret_token);
this._ready = api.products.ensure(stripe, config.product).then((product) => {
return Promise.all(
config.plans.map(plan => api.plans.ensure(stripe, plan, product))
).then((plans) => {
this._stripe = stripe;
this._product = product;
this._plans = plans;
return {
product,
plans
};
});
});
return this._ready;
}
getConfig() {
if (!this._plans) {
throw new Error('StripePaymentProcessor must be configured()');
}
return this._ready.then(() => {
return this._plans;
});
}
createSubscription(member, metadata) {
if (!this._stripe) {
throw new Error('StripePaymentProcessor must be configured()');
}
if (!metadata.stripeToken) {
throw new Error('createSubscription(member, {stripeToken}) missing stripeToken');
}
if (!metadata.plan) {
throw new Error('createSubscription(member, {plan}) missing plan');
}
return this._ready.then(() => {
const plan = this._plans.find(plan => plan.nickname === metadata.plan);
if (!plan) {
throw new Error('Unknown plan');
}
return api.subscriptions.create(this._stripe, member, {
plan,
stripeToken: metadata.stripeToken
});
});
}
getSubscription(member) {
if (!this._stripe) {
throw new Error('StripePaymentProcessor must be configured()');
}
return this._ready.then(() => {
return api.subscriptions.get(this._stripe, member);
});
}
};

View File

@ -9,9 +9,10 @@ module.exports = function ({
const keyStore = jose.JWK.createKeyStore();
const keyStoreReady = keyStore.add(privateKey, 'pem');
function encodeToken({sub, aud = issuer}) {
function encodeToken({sub, aud = issuer, plans}) {
return keyStoreReady.then(jwk => jwt.sign({
sub,
plans,
kid: jwk.kid
}, privateKey, {
algorithm: 'RS512',

View File

@ -1,4 +1,5 @@
module.exports = function ({
subscriptions,
createMember,
updateMember,
getMember,
@ -26,12 +27,30 @@ module.exports = function ({
});
}
function get(...args) {
return getMember(...args).then((member) => {
return subscriptions.getAdapters().then((adapters) => {
return Promise.all(adapters.map((adapter) => {
return subscriptions.getSubscription(member, {
adapter
}).then((subscription) => {
return Object.assign(subscription, {adapter});
});
}));
}).then((subscriptions) => {
return Object.assign({}, member, {
subscriptions: subscriptions.filter(sub => sub.status === 'active')
});
});
});
}
return {
requestPasswordReset,
resetPassword,
create: createMember,
validate: validateMember,
get: getMember,
list: listMembers
list: listMembers,
get
};
};

View File

@ -77,6 +77,8 @@ const issuer = siteOrigin;
const ssoOrigin = siteOrigin;
let mailer;
const membersConfig = config.get('members');
function sendEmail(member, {token}) {
if (!(mailer instanceof mail.GhostMailer)) {
mailer = new mail.GhostMailer();
@ -105,13 +107,16 @@ function sendEmail(member, {token}) {
}
const api = MembersApi({
config: {
authConfig: {
issuer,
publicKey,
privateKey,
sessionSecret,
ssoOrigin
},
paymentConfig: {
processors: membersConfig.paymentProcessors
},
validateAudience,
createMember,
getMember,

View File

@ -101,6 +101,7 @@
"semver": "5.6.0",
"simple-dom": "0.3.2",
"simple-html-tokenizer": "0.5.7",
"stripe": "^6.22.0",
"superagent": "4.1.0",
"unidecode": "0.1.8",
"uuid": "3.3.2",

View File

@ -5134,7 +5134,7 @@ q@^1.1.2:
version "1.5.1"
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
qs@6.5.2, qs@~6.5.2:
qs@6.5.2, qs@~6.5.1, qs@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
@ -5987,6 +5987,14 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
stripe@^6.22.0:
version "6.22.0"
resolved "https://registry.yarnpkg.com/stripe/-/stripe-6.22.0.tgz#8c1956b04bbbaa736581ff56987feafabd8deff1"
dependencies:
lodash.isplainobject "^4.0.6"
qs "~6.5.1"
safe-buffer "^5.1.1"
superagent@4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/superagent/-/superagent-4.1.0.tgz#c465c2de41df2b8d05c165cbe403e280790cdfd5"