mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-15 03:01:37 -05:00
✨ Added complimentary member subscription (#11537)
no issue - We need a way to simulate "premium" membership without any payment from members' side. For this new "Complimentary" plan is introduced - Allows `comped` flag as an input only on `PUT /members/:id` endpoint which sets free subscriptions based on "complimentary" plan on the member - Added `comped` flag to members endpoint responses - Bumped members-api to 0.12.0. This version supports new set/cancel complimentary subscription methods
This commit is contained in:
parent
07e1a2406b
commit
25f11bbf1c
10 changed files with 131 additions and 36 deletions
core
package.jsonyarn.lock
|
@ -6,24 +6,28 @@ const membersService = require('../../services/members');
|
|||
const common = require('../../lib/common');
|
||||
const fsLib = require('../../lib/fs');
|
||||
|
||||
const listMembers = async function (options) {
|
||||
const res = (await models.Member.findPage(options));
|
||||
const members = res.data.map(model => model.toJSON(options));
|
||||
|
||||
const decorateWithSubscriptions = async function (member) {
|
||||
// NOTE: this logic is here until relations between Members/MemberStripeCustomer/StripeCustomerSubscription
|
||||
// are in place
|
||||
const membersWithSubscriptions = await Promise.all(members.map(async function (member) {
|
||||
const subscriptions = await membersService.api.members.getStripeSubscriptions(member);
|
||||
const subscriptions = await membersService.api.members.getStripeSubscriptions(member);
|
||||
|
||||
return Object.assign(member, {
|
||||
stripe: {
|
||||
subscriptions
|
||||
}
|
||||
});
|
||||
return Object.assign(member, {
|
||||
stripe: {
|
||||
subscriptions
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const listMembers = async function (options) {
|
||||
const res = (await models.Member.findPage(options));
|
||||
const memberModels = res.data.map(model => model.toJSON(options));
|
||||
|
||||
const members = await Promise.all(memberModels.map(async function (member) {
|
||||
return decorateWithSubscriptions(member);
|
||||
}));
|
||||
|
||||
return {
|
||||
members: membersWithSubscriptions,
|
||||
members: members,
|
||||
meta: res.meta
|
||||
};
|
||||
};
|
||||
|
@ -55,25 +59,17 @@ const members = {
|
|||
validation: {},
|
||||
permissions: true,
|
||||
async query(frame) {
|
||||
let member = await models.Member.findOne(frame.data, frame.options);
|
||||
let model = await models.Member.findOne(frame.data, frame.options);
|
||||
|
||||
if (!member) {
|
||||
if (!model) {
|
||||
throw new common.errors.NotFoundError({
|
||||
message: common.i18n.t('errors.api.members.memberNotFound')
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: this logic is here until relations between Members/MemberStripeCustomer/StripeCustomerSubscription
|
||||
// are in place
|
||||
const subscriptions = await membersService.api.members.getStripeSubscriptions(member);
|
||||
member = member.toJSON(frame.options);
|
||||
Object.assign(member, {
|
||||
stripe: {
|
||||
subscriptions
|
||||
}
|
||||
});
|
||||
const member = model.toJSON(frame.options);
|
||||
|
||||
return member;
|
||||
return decorateWithSubscriptions(member);
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -97,13 +93,15 @@ const members = {
|
|||
permissions: true,
|
||||
async query(frame) {
|
||||
try {
|
||||
const member = await models.Member.add(frame.data.members[0], frame.options);
|
||||
const model = await models.Member.add(frame.data.members[0], frame.options);
|
||||
|
||||
if (frame.options.send_email) {
|
||||
await membersService.api.sendEmailWithMagicLink(member.get('email'), frame.options.email_type);
|
||||
await membersService.api.sendEmailWithMagicLink(model.get('email'), frame.options.email_type);
|
||||
}
|
||||
|
||||
return member;
|
||||
const member = model.toJSON(frame.options);
|
||||
|
||||
return decorateWithSubscriptions(member);
|
||||
} catch (error) {
|
||||
if (error.code && error.message.toLowerCase().indexOf('unique') !== -1) {
|
||||
throw new common.errors.ValidationError({message: common.i18n.t('errors.api.members.memberAlreadyExists')});
|
||||
|
@ -129,9 +127,24 @@ const members = {
|
|||
},
|
||||
permissions: true,
|
||||
async query(frame) {
|
||||
const member = await models.Member.edit(frame.data.members[0], frame.options);
|
||||
const model = await models.Member.edit(frame.data.members[0], frame.options);
|
||||
|
||||
return member;
|
||||
const member = model.toJSON(frame.options);
|
||||
|
||||
const subscriptions = await membersService.api.members.getStripeSubscriptions(member);
|
||||
const compedSubscriptions = subscriptions.filter(sub => (sub.plan.nickname === 'Complimentary'));
|
||||
|
||||
if (frame.data.members[0].comped !== undefined && (frame.data.members[0].comped !== compedSubscriptions)) {
|
||||
const hasCompedSubscription = !!(compedSubscriptions.length);
|
||||
|
||||
if (frame.data.members[0].comped && !hasCompedSubscription) {
|
||||
await membersService.api.members.setComplimentarySubscription(member);
|
||||
} else if (!(frame.data.members[0].comped) && hasCompedSubscription) {
|
||||
await membersService.api.members.cancelComplimentarySubscription(member);
|
||||
}
|
||||
}
|
||||
|
||||
return decorateWithSubscriptions(member);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -6,7 +6,10 @@ module.exports = {
|
|||
browse(data, apiConfig, frame) {
|
||||
debug('browse');
|
||||
|
||||
frame.response = mapper.mapMember(data, frame);
|
||||
frame.response = {
|
||||
members: data.members.map(member => mapper.mapMember(member, frame)),
|
||||
meta: data.meta
|
||||
};
|
||||
},
|
||||
|
||||
add(data, apiConfig, frame) {
|
||||
|
|
|
@ -142,6 +142,18 @@ const mapAction = (model, frame) => {
|
|||
|
||||
const mapMember = (model, frame) => {
|
||||
const jsonModel = model.toJSON ? model.toJSON(frame.options) : model;
|
||||
|
||||
if (_.get(jsonModel, 'stripe.subscriptions')) {
|
||||
let compedSubscriptions = _.get(jsonModel, 'stripe.subscriptions').filter(sub => (sub.plan.nickname === 'Complimentary'));
|
||||
const hasCompedSubscription = !!(compedSubscriptions.length);
|
||||
|
||||
// NOTE: `frame.options.fields` has to be taken into account in the same way as for `stripe.subscriptions`
|
||||
// at the moment of implementation fields were not fully supported by members endpoints
|
||||
Object.assign(jsonModel, {
|
||||
comped: hasCompedSubscription
|
||||
});
|
||||
}
|
||||
|
||||
return jsonModel;
|
||||
};
|
||||
|
||||
|
|
|
@ -28,6 +28,9 @@
|
|||
"subscribed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"comped": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"strip": true
|
||||
},
|
||||
|
|
|
@ -28,6 +28,9 @@
|
|||
"subscribed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"comped": {
|
||||
"strip": "true"
|
||||
},
|
||||
"id": {
|
||||
"strip": true
|
||||
},
|
||||
|
|
|
@ -5,6 +5,13 @@ const crypto = require('crypto');
|
|||
const common = require('../../lib/common');
|
||||
const urlUtils = require('../../lib/url-utils');
|
||||
|
||||
const COMPLIMENTARY_PLAN = {
|
||||
name: 'Complimentary',
|
||||
currency: 'usd',
|
||||
interval: 'year',
|
||||
amount: '0'
|
||||
};
|
||||
|
||||
// NOTE: the function is an exact duplicate of one in GhostMailer should be extracted
|
||||
// into a common lib once it needs to be reused anywhere else again
|
||||
function getDomain() {
|
||||
|
@ -44,6 +51,8 @@ function getStripePaymentConfig() {
|
|||
return null;
|
||||
}
|
||||
|
||||
stripePaymentProcessor.config.plans.push(COMPLIMENTARY_PLAN);
|
||||
|
||||
const webhookHandlerUrl = new URL('/members/webhooks/stripe', siteUrl);
|
||||
|
||||
const checkoutSuccessUrl = new URL(siteUrl);
|
||||
|
|
|
@ -166,7 +166,7 @@ describe('Members API', function () {
|
|||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.members);
|
||||
jsonResponse.members.should.have.length(1);
|
||||
localUtils.API.checkResponse(jsonResponse.members[0], 'member');
|
||||
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
|
||||
jsonResponse.members[0].name.should.equal(memberChanged.name);
|
||||
jsonResponse.members[0].email.should.not.equal(memberChanged.email);
|
||||
jsonResponse.members[0].email.should.equal(memberToChange.email);
|
||||
|
@ -174,6 +174,57 @@ describe('Members API', function () {
|
|||
});
|
||||
});
|
||||
|
||||
// NOTE: this test should be enabled and expanded once test suite fully supports Stripe mocking
|
||||
it.skip('Can set a "Complimentary" subscription', function () {
|
||||
const memberToChange = {
|
||||
name: 'Comped Member',
|
||||
email: 'member2comp@test.com'
|
||||
};
|
||||
|
||||
const memberChanged = {
|
||||
comped: true
|
||||
};
|
||||
|
||||
return request
|
||||
.post(localUtils.API.getApiQuery(`members/`))
|
||||
.send({members: [memberToChange]})
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(201)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
const jsonResponse = res.body;
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.members);
|
||||
jsonResponse.members.should.have.length(1);
|
||||
|
||||
return jsonResponse.members[0];
|
||||
})
|
||||
.then((newMember) => {
|
||||
return request
|
||||
.put(localUtils.API.getApiQuery(`members/${newMember.id}/`))
|
||||
.send({members: [memberChanged]})
|
||||
.set('Origin', config.get('url'))
|
||||
.expect('Content-Type', /json/)
|
||||
.expect('Cache-Control', testUtils.cacheRules.private)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
should.not.exist(res.headers['x-cache-invalidate']);
|
||||
|
||||
const jsonResponse = res.body;
|
||||
|
||||
should.exist(jsonResponse);
|
||||
should.exist(jsonResponse.members);
|
||||
jsonResponse.members.should.have.length(1);
|
||||
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'stripe');
|
||||
jsonResponse.members[0].name.should.equal(memberToChange.name);
|
||||
jsonResponse.members[0].email.should.equal(memberToChange.email);
|
||||
jsonResponse.members[0].comped.should.equal(memberToChange.comped);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Can destroy', function () {
|
||||
const member = {
|
||||
name: 'test',
|
||||
|
|
|
@ -55,6 +55,7 @@ const expectedProperties = {
|
|||
,
|
||||
member: _(schema.members)
|
||||
.keys()
|
||||
.concat('comped')
|
||||
,
|
||||
role: _(schema.roles)
|
||||
.keys()
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
"@nexes/nql": "0.3.0",
|
||||
"@sentry/node": "5.11.1",
|
||||
"@tryghost/helpers": "1.1.22",
|
||||
"@tryghost/members-api": "0.11.2",
|
||||
"@tryghost/members-api": "0.12.0",
|
||||
"@tryghost/members-ssr": "0.7.4",
|
||||
"@tryghost/social-urls": "0.1.5",
|
||||
"@tryghost/string": "^0.1.3",
|
||||
|
|
|
@ -316,10 +316,10 @@
|
|||
jsonwebtoken "^8.5.1"
|
||||
lodash "^4.17.15"
|
||||
|
||||
"@tryghost/members-api@0.11.2":
|
||||
version "0.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.11.2.tgz#7cb957792231fadcb4e01520a57fbe8865cc10db"
|
||||
integrity sha512-ntFfYJK3E2WJtJDlgUFOZ/7TKR1u71iNcV3lBfIkj/rpxfXGvW/SnU08sLxxieTFmYK/xJAWdrIE9vYJmuTlhw==
|
||||
"@tryghost/members-api@0.12.0":
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.12.0.tgz#f3b32d8216a5bfc6c4c5404634ff661a86848548"
|
||||
integrity sha512-qQAFr+QcedQDWWUaxuDC1XI3Kgcq28fj0bCOY+FqkXgSmec0A6x6glps+GY/gzQF6FfH7GBrRByJ0a+wriIqog==
|
||||
dependencies:
|
||||
"@tryghost/magic-link" "^0.3.3"
|
||||
bluebird "^3.5.4"
|
||||
|
|
Loading…
Add table
Reference in a new issue