Updated subscriptions for Members Admin API

refs https://github.com/TryGhost/Team/issues/616

We need a way to assign Products to Members via a Subscription, and we've
followed the same pattern as the editSubscription method for the Members API
controller, which acts upon Subscriptions as a nested resource.

Subscriptions now are linked to products, and we've included those links by
default in the Member Admin API as we already include subscriptions by
default, and Products are now a core part of the Members feature-set.
This commit is contained in:
Fabien 'egg' O'Carroll 2021-04-26 17:14:34 +01:00 committed by GitHub
parent 7bce05ab86
commit 33f26fbf32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 87 additions and 12 deletions

View File

@ -40,7 +40,7 @@ module.exports = {
permissions: true,
validation: {},
async query(frame) {
frame.options.withRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer'];
frame.options.withRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
const page = await membersService.api.members.list(frame.options);
return page;
@ -65,7 +65,7 @@ module.exports = {
},
permissions: true,
async query(frame) {
const defaultWithRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer'];
const defaultWithRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
if (!frame.options.withRelated) {
frame.options.withRelated = defaultWithRelated;
@ -109,7 +109,7 @@ module.exports = {
permissions: true,
async query(frame) {
let member;
frame.options.withRelated = ['stripeSubscriptions', 'stripeSubscriptions.customer'];
frame.options.withRelated = ['stripeSubscriptions', 'products', 'labels', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
try {
if (!membersService.config.isStripeConnected()
&& (frame.data.members[0].stripe_customer_id || frame.data.members[0].comped)) {
@ -185,7 +185,7 @@ module.exports = {
permissions: true,
async query(frame) {
try {
frame.options.withRelated = ['stripeSubscriptions', 'labels'];
frame.options.withRelated = ['stripeSubscriptions', 'products', 'labels', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct'];
const member = await membersService.api.members.update(frame.data.members[0], frame.options);
const hasCompedSubscription = !!member.related('stripeSubscriptions').find(sub => sub.get('plan_nickname') === 'Complimentary' && sub.get('status') === 'active');
@ -255,7 +255,51 @@ module.exports = {
}
});
let model = await membersService.api.members.get({id: frame.options.id}, {
withRelated: ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer']
withRelated: ['labels', 'products', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct']
});
if (!model) {
throw new errors.NotFoundError({
message: i18n.t('errors.api.members.memberNotFound')
});
}
return model;
}
},
createSubscription: {
statusCode: 200,
headers: {},
options: [
'id'
],
data: [
'stripe_price_id'
],
validation: {
options: {
id: {
required: true
}
},
data: {
stripe_price_id: {
required: true
}
}
},
permissions: {
method: 'edit'
},
async query(frame) {
await membersService.api.members.createSubscription({
id: frame.options.id,
subscription: {
stripe_price_id: frame.data.stripe_price_id
}
});
let model = await membersService.api.members.get({id: frame.options.id}, {
withRelated: ['labels', 'products', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'stripeSubscriptions.stripePrice.stripeProduct']
});
if (!model) {
throw new errors.NotFoundError({

View File

@ -10,6 +10,7 @@ module.exports = {
edit: createSerializer('edit', singleMember),
add: createSerializer('add', singleMember),
editSubscription: createSerializer('editSubscription', singleMember),
createSubscription: createSerializer('createSubscription', singleMember),
bulkDestroy: createSerializer('bulkDestroy', passthrough),
exportCSV: createSerializer('exportCSV', exportCSV),
@ -197,11 +198,16 @@ function createSerializer(debugString, serialize) {
* @prop {null|string} customer.name
* @prop {string} customer.email
*
* @prop {Object} plan
* @prop {string} plan.id
* @prop {string} plan.nickname
* @prop {number} plan.amount
* @prop {string} plan.currency
* @prop {Object} price
* @prop {string} price.id
* @prop {string} price.nickname
* @prop {number} price.amount
* @prop {string} price.interval
* @prop {string} price.currency
*
* @prop {Object} price.product
* @prop {string} price.product.id
* @prop {string} price.product.product_id
*/
/**

View File

@ -7,10 +7,14 @@ const StripeCustomerSubscription = ghostBookshelf.Model.extend({
return this.belongsTo('MemberStripeCustomer', 'customer_id', 'customer_id');
},
stripePrice() {
return this.hasOne('StripePrice', 'stripe_price_id', 'stripe_price_id');
},
serialize(options) {
const defaultSerializedObject = ghostBookshelf.Model.prototype.serialize.call(this, options);
return {
const serialized = {
id: defaultSerializedObject.subscription_id,
customer: {
id: defaultSerializedObject.customer_id,
@ -32,6 +36,26 @@ const StripeCustomerSubscription = ghostBookshelf.Model.extend({
cancellation_reason: defaultSerializedObject.cancellation_reason,
current_period_end: defaultSerializedObject.current_period_end
};
if (defaultSerializedObject.stripePrice) {
serialized.price = {
id: defaultSerializedObject.stripePrice.stripe_price_id,
nickname: defaultSerializedObject.stripePrice.nickname,
amount: defaultSerializedObject.stripePrice.amount,
interval: defaultSerializedObject.stripePrice.interval,
currency: String.prototype.toUpperCase.call(defaultSerializedObject.stripePrice.currency)
};
if (defaultSerializedObject.stripePrice.stripeProduct) {
serialized.price.product = {
id: defaultSerializedObject.stripePrice.stripeProduct.stripe_product_id,
name: defaultSerializedObject.stripePrice.stripeProduct.name,
product_id: defaultSerializedObject.stripePrice.stripeProduct.product_id
};
}
}
return serialized;
}
}, {

View File

@ -121,6 +121,7 @@ module.exports = function apiRoutes() {
router.put('/members/:id', mw.authAdminApi, http(apiCanary.members.edit));
router.del('/members/:id', mw.authAdminApi, http(apiCanary.members.destroy));
router.post('/members/:id/subscriptions/', mw.authAdminApi, http(apiCanary.members.createSubscription));
router.put('/members/:id/subscriptions/:subscription_id', mw.authAdminApi, http(apiCanary.members.editSubscription));
router.get('/members/:id/signin_urls', mw.authAdminApi, http(apiCanary.memberSigninUrls.read));

View File

@ -234,7 +234,7 @@ describe('Members API', function () {
should.exist(jsonResponse2);
should.exist(jsonResponse2.members);
jsonResponse2.members.should.have.length(1);
localUtils.API.checkResponse(jsonResponse2.members[0], 'member', 'subscriptions');
localUtils.API.checkResponse(jsonResponse2.members[0], 'member', ['subscriptions', 'products']);
jsonResponse2.members[0].name.should.equal(memberChanged.name);
jsonResponse2.members[0].email.should.equal(memberChanged.email);
jsonResponse2.members[0].email.should.not.equal(memberToChange.email);