0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

Added complimentary member subscription ()

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:
Naz Gargol 2020-01-28 11:25:00 +07:00 committed by GitHub
parent 07e1a2406b
commit 25f11bbf1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 131 additions and 36 deletions
core
server
api/canary
members.js
utils
serializers/output
validators/input/schemas
services/members
test/regression/api/canary/admin
package.jsonyarn.lock

View file

@ -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);
}
},

View file

@ -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) {

View file

@ -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;
};

View file

@ -28,6 +28,9 @@
"subscribed": {
"type": "boolean"
},
"comped": {
"type": "boolean"
},
"id": {
"strip": true
},

View file

@ -28,6 +28,9 @@
"subscribed": {
"type": "boolean"
},
"comped": {
"strip": "true"
},
"id": {
"strip": true
},

View file

@ -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);

View file

@ -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',

View file

@ -55,6 +55,7 @@ const expectedProperties = {
,
member: _(schema.members)
.keys()
.concat('comped')
,
role: _(schema.roles)
.keys()

View file

@ -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",

View file

@ -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"