Added member's subscription cancellation helper {{cancel_link}} (#11434)

no issue

- The helper allows generating HTML needed to cancel or continue the member's subscription depending on subscription state.
- Added public members endpoint to allow updating subscription's `cancel_at_period_end` attribute available at: `PUT /api/canary/members/subscriptions/:id/`
- Added client-side hook to allow calling subscription cancellation. Allows to create elements with `data-members-cancel-subscription` / `data-members-continue-subscription` attributes which would call subscription update.
- Updated schema and added migration for `current_period_end` column
- As discussed we only add a single column to  subscriptions table to avoid preoptimizing for future cases
- Added {{cancel_link}} helper
- Added error handling for {{cancel_link}} when members are disabled
- Added test coverage for {{cancel_link}} helper
- Bumped @tryghost/members-api version to 0.10.2. Needed to use `updateSubscription` middleware
- Bumped gscan to 3.2.0. Needed to recognize new {{cancel_link}} helper
This commit is contained in:
Naz Gargol 2019-12-12 19:59:15 +07:00 committed by GitHub
parent 5997343279
commit e277c6bad3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 344 additions and 42 deletions

View File

@ -0,0 +1,32 @@
// # {{cancel_link}} Helper
// Usage: `{{cancel_link}}`, `{{cancel_link class="custom-cancel-class"}}`, `{{cancel_link cancelLabel="Cancel please!"}}`
//
// Should be used inside of a subscription context, e.g.: `{{#foreach @member.subscriptions}} {{cancel_link}} {{/foreach}}`
// Outputs cancel/renew links to manage subscription renewal after the subscription period ends.
//
// Defaults to class="cancel-subscription-link" errorClass="cancel-subscription-error" cancelLabel="Cancel subscription" continueLabel="Continue subscription"
const proxy = require('./proxy');
const templates = proxy.templates;
const errors = proxy.errors;
const i18n = proxy.i18n;
module.exports = function excerpt(options) {
let truncateOptions = (options || {}).hash || {};
if (this.id === undefined || this.cancel_at_period_end === undefined) {
throw new errors.IncorrectUsageError({message: i18n.t('warnings.helpers.cancel_link.invalidData')});
}
const data = {
id: this.id,
cancel_at_period_end: this.cancel_at_period_end,
class: truncateOptions.class || 'gh-subscription-cancel',
errorClass: truncateOptions.errorClass || 'gh-error gh-error-subscription-cancel',
cancelLabel: truncateOptions.cancelLabel || 'Cancel subscription',
continueLabel: truncateOptions.continueLabel || 'Continue subscription'
};
return templates.execute('cancel_link', data);
};

View File

@ -1,13 +1,17 @@
var coreHelpers = {},
register = require('./register'),
registerThemeHelper = register.registerThemeHelper,
registerAsyncThemeHelper = register.registerAsyncThemeHelper,
registerAllCoreHelpers;
const proxy = require('./proxy');
const register = require('./register');
const coreHelpers = {};
const registerThemeHelper = register.registerThemeHelper;
const registerAsyncThemeHelper = register.registerAsyncThemeHelper;
let registerAllCoreHelpers;
coreHelpers.asset = require('./asset');
coreHelpers.author = require('./author');
coreHelpers.authors = require('./authors');
coreHelpers.body_class = require('./body_class');
coreHelpers.cancel_link = require('./cancel_link');
coreHelpers.concat = require('./concat');
coreHelpers.content = require('./content');
coreHelpers.date = require('./date');
@ -40,12 +44,26 @@ coreHelpers.title = require('./title');
coreHelpers.twitter_url = require('./twitter_url');
coreHelpers.url = require('./url');
function labsEnabledMembers() {
let self = this, args = arguments;
return proxy.labs.enabledHelper({
flagKey: 'members',
flagName: 'Members',
helperName: 'cancel_link',
helpUrl: 'https://ghost.org/faq/members/'
}, () => {
return coreHelpers.cancel_link.apply(self, args);
});
}
registerAllCoreHelpers = function registerAllCoreHelpers() {
// Register theme helpers
registerThemeHelper('asset', coreHelpers.asset);
registerThemeHelper('author', coreHelpers.author);
registerThemeHelper('authors', coreHelpers.authors);
registerThemeHelper('body_class', coreHelpers.body_class);
registerThemeHelper('cancel_link', labsEnabledMembers);
registerThemeHelper('concat', coreHelpers.concat);
registerThemeHelper('content', coreHelpers.content);
registerThemeHelper('date', coreHelpers.date);

View File

@ -0,0 +1,11 @@
{{#if cancel_at_period_end}}
<a class="{{class}}" data-members-continue-subscription="{{id}}" href="javascript:">
{{continueLabel}}
</a>
{{else}}
<a class="{{class}}" data-members-cancel-subscription="{{id}}" href="javascript:">
{{cancelLabel}}
</a>
{{/if}}
<span class="{{errorClass}}" data-members-error><!-- error message will appear here --></span>

View File

@ -0,0 +1,25 @@
const commands = require('../../../schema').commands;
module.exports.up = commands.createColumnMigration({
table: 'members_stripe_customers_subscriptions',
column: 'cancel_at_period_end',
dbIsInCorrectState(columnExists) {
return columnExists === true;
},
operation: commands.addColumn,
operationVerb: 'Adding'
});
module.exports.down = commands.createColumnMigration({
table: 'members_stripe_customers_subscriptions',
column: 'cancel_at_period_end',
dbIsInCorrectState(columnExists) {
return columnExists === false;
},
operation: commands.dropColumn,
operationVerb: 'Removing'
});
module.exports.config = {
transaction: true
};

View File

@ -351,6 +351,7 @@ module.exports = {
subscription_id: {type: 'string', maxlength: 255, nullable: false, unique: false},
plan_id: {type: 'string', maxlength: 255, nullable: false, unique: false},
status: {type: 'string', maxlength: 50, nullable: false},
cancel_at_period_end: {type: 'bool', nullable: false, defaultTo: false},
current_period_end: {type: 'dateTime', nullable: false},
start_date: {type: 'dateTime', nullable: false},
default_payment_card_last4: {type: 'string', maxlength: 4, nullable: true},

View File

@ -133,6 +133,106 @@ Array.prototype.forEach.call(document.querySelectorAll('[data-members-signout]')
el.addEventListener('click', clickHandler);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-members-cancel-subscription]'), function (el) {
var errorEl = el.parentElement.querySelector('[data-members-error]');
function clickHandler(event) {
el.removeEventListener('click', clickHandler);
event.preventDefault();
el.classList.remove('error');
el.classList.add('loading');
var subscriptionId = el.dataset.membersCancelSubscription;
if (errorEl) {
errorEl.innerText = '';
}
return fetch('{{blog-url}}/members/ssr', {
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
return null;
}
return res.text();
}).then(function (identity) {
return fetch(`{{admin-url}}/api/canary/members/subscriptions/${subscriptionId}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
identity: identity,
cancel_at_period_end: true
})
});
}).then(function (res) {
if (res.ok) {
window.location.reload();
} else {
el.addEventListener('click', clickHandler);
el.classList.remove('loading');
el.classList.add('error');
if (errorEl) {
errorEl.innerText = 'There was an error cancelling your subscription, please try again.';
}
}
});
}
el.addEventListener('click', clickHandler);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-members-continue-subscription]'), function (el) {
var errorEl = el.parentElement.querySelector('[data-members-error]');
function clickHandler(event) {
el.removeEventListener('click', clickHandler);
event.preventDefault();
el.classList.remove('error');
el.classList.add('loading');
var subscriptionId = el.dataset.membersContinueSubscription;
if (errorEl) {
errorEl.innerText = '';
}
return fetch('{{blog-url}}/members/ssr', {
credentials: 'same-origin'
}).then(function (res) {
if (!res.ok) {
return null;
}
return res.text();
}).then(function (identity) {
return fetch(`{{admin-url}}/api/canary/members/subscriptions/${subscriptionId}/`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
identity: identity,
cancel_at_period_end: false
})
});
}).then(function (res) {
if (res.ok) {
window.location.reload();
} else {
el.addEventListener('click', clickHandler);
el.classList.remove('loading');
el.classList.add('error');
if (errorEl) {
errorEl.innerText = 'There was an error continuing your subscription, please try again.';
}
}
});
}
el.addEventListener('click', clickHandler);
});
var url = new URL(window.location);
if (url.searchParams.get('token')) {
url.searchParams.delete('token');

View File

@ -565,6 +565,9 @@
"asset": {
"pathIsRequired": "The \\{\\{asset\\}\\} helper must be passed a path"
},
"cancel_link": {
"invalidData": "The \\{\\{cancel_link\\}\\} helper was used outside of a subscription context. See https://ghost.org/docs/api/handlebars-themes/helpers/cancel_link/."
},
"foreach": {
"iteratorNeeded": "Need to pass an iterator to #foreach"
},

View File

@ -21,6 +21,7 @@ module.exports = function setupMembersApiApp() {
// NOTE: this is wrapped in a function to ensure we always go via the getter
apiApp.post('/send-magic-link', (req, res, next) => membersService.api.middleware.sendMagicLink(req, res, next));
apiApp.post('/create-stripe-checkout-session', (req, res, next) => membersService.api.middleware.createCheckoutSession(req, res, next));
apiApp.put('/subscriptions/:id', (req, res, next) => membersService.api.middleware.updateSubscription(req, res, next));
// API error handling
apiApp.use(shared.middlewares.errorHandler.resourceNotFound);

View File

@ -19,7 +19,7 @@ var should = require('should'),
*/
describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '773f8f6cd4267f50aec6af8c8b1edbd2';
const currentSchemaHash = '3ec33e7039a21dba597ada2a03de0526';
const currentFixturesHash = '1a0f96fa1d8b976d663eb06719be031c';
// If this test is failing, then it is likely a change has been made that requires a DB version bump,

View File

@ -0,0 +1,113 @@
const should = require('should');
const hbs = require('../../../frontend/services/themes/engine');
const helpers = require('../../../frontend/helpers');
const configUtils = require('../../utils/configUtils');
describe('{{cancel_link}} helper', function () {
before(function (done) {
hbs.express4({partialsDir: [configUtils.config.get('paths').helperTemplates]});
hbs.cachePartials(function () {
done();
});
});
const defaultLinkClass = /class="gh-subscription-cancel"/;
const defaultErrorElementClass = /class="gh-error gh-error-subscription-cancel"/;
const defaultCancelLinkText = /Cancel subscription/;
const defaultContinueLinkText = /Continue subscription/;
it('should throw if subscription data is incorrect', function () {
var runHelper = function (data) {
return function () {
helpers.cancel_link.call(data);
};
}, expectedMessage = 'The {{cancel_link}} helper was used outside of a subscription context. See https://ghost.org/docs/api/handlebars-themes/helpers/cancel_link/.';
runHelper('not an object').should.throwError(expectedMessage);
runHelper(function () {
}).should.throwError(expectedMessage);
});
it('can render cancel subscription link', function () {
const rendered = helpers.cancel_link.call({
id: 'sub_cancel',
cancel_at_period_end: false
});
should.exist(rendered);
rendered.string.should.match(defaultLinkClass);
rendered.string.should.match(/data-members-cancel-subscription="sub_cancel"/);
rendered.string.should.match(defaultCancelLinkText);
rendered.string.should.match(defaultErrorElementClass);
});
it('can render continue subscription link', function () {
const rendered = helpers.cancel_link.call({
id: 'sub_continue',
cancel_at_period_end: true
});
should.exist(rendered);
rendered.string.should.match(defaultLinkClass);
rendered.string.should.match(/data-members-continue-subscription="sub_continue"/);
rendered.string.should.match(defaultContinueLinkText);
});
it('can render custom link class', function () {
const rendered = helpers.cancel_link.call({
id: 'sub_cancel',
cancel_at_period_end: false
}, {
hash: {
class: 'custom-link-class'
}
});
should.exist(rendered);
rendered.string.should.match(/custom-link-class/);
});
it('can render custom error class', function () {
const rendered = helpers.cancel_link.call({
id: 'sub_cancel',
cancel_at_period_end: false
}, {
hash: {
errorClass: 'custom-error-class'
}
});
should.exist(rendered);
rendered.string.should.match(/custom-error-class/);
});
it('can render custom cancel subscription link attributes', function () {
const rendered = helpers.cancel_link.call({
id: 'sub_cancel',
cancel_at_period_end: false
}, {
hash: {
cancelLabel: 'custom cancel link text'
}
});
should.exist(rendered);
rendered.string.should.match(/custom cancel link text/);
});
it('can render custom continue subscription link attributes', function () {
const rendered = helpers.cancel_link.call({
id: 'sub_cancel',
cancel_at_period_end: true
}, {
hash: {
continueLabel: 'custom continue link text'
}
});
should.exist(rendered);
rendered.string.should.match(/custom continue link text/);
});
});

View File

@ -5,9 +5,7 @@ var should = require('should'),
// Stuff we are testing
helpers = require('../../../frontend/helpers'),
models = require('../../../server/models'),
api = require('../../../server/api'),
labs = require('../../../server/services/labs');
api = require('../../../server/api');
describe('{{#get}} helper', function () {
var fn, inverse;

View File

@ -8,7 +8,7 @@ var should = require('should'),
describe('Helpers', function () {
var hbsHelpers = ['each', 'if', 'unless', 'with', 'helperMissing', 'blockHelperMissing', 'log', 'lookup', 'block', 'contentFor'],
ghostHelpers = [
'asset', 'author', 'authors', 'body_class', 'concat', 'content', 'date', 'encode', 'excerpt', 'facebook_url', 'foreach', 'get',
'asset', 'author', 'authors', 'body_class', 'cancel_link', 'concat', 'content', 'date', 'encode', 'excerpt', 'facebook_url', 'foreach', 'get',
'ghost_foot', 'ghost_head', 'has', 'img_url', 'is', 'lang', 'link', 'link_class', 'meta_description', 'meta_title', 'navigation',
'next_post', 'page_url', 'pagination', 'plural', 'post_class', 'prev_post', 'reading_time', 't', 'tags', 'title', 'twitter_url',
'url'

View File

@ -41,7 +41,7 @@
"dependencies": {
"@nexes/nql": "0.3.0",
"@tryghost/helpers": "1.1.19",
"@tryghost/members-api": "0.10.1",
"@tryghost/members-api": "0.10.2",
"@tryghost/members-ssr": "0.7.3",
"@tryghost/social-urls": "0.1.4",
"@tryghost/string": "^0.1.3",
@ -80,7 +80,7 @@
"ghost-storage-base": "0.0.3",
"glob": "7.1.6",
"got": "9.6.0",
"gscan": "3.1.1",
"gscan": "3.2.0",
"html-to-text": "5.1.1",
"image-size": "0.8.3",
"intl": "1.2.5",

View File

@ -237,10 +237,10 @@
jsonwebtoken "^8.5.1"
lodash "^4.17.15"
"@tryghost/members-api@0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.10.1.tgz#07b37df38fb937ae99d491cb308bc2f18a2dfaf7"
integrity sha512-38dA8nVh3UTSJEDYKNKy98sQArokTrUoHTELEc2HddCrEhAkfezghQEDLikty8RQ9IQs4XpKh3q7JO4bQbK6Ww==
"@tryghost/members-api@0.10.2":
version "0.10.2"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-0.10.2.tgz#c28d83a7e9817310a6e7a843f8a2a4669b52856e"
integrity sha512-dbA/NO6fpIxDu+b6tH3TN3i2tUrCGnue4vvOuSm0tctEWOTSIceaaiS64HFpncrsJ8obqvqY6ekilgASQWnZ1w==
dependencies:
"@tryghost/magic-link" "^0.3.2"
bluebird "^3.5.4"
@ -2400,25 +2400,25 @@ error@^7.0.0:
string-template "~0.2.1"
es-abstract@^1.5.1:
version "1.16.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.16.0.tgz#d3a26dc9c3283ac9750dca569586e976d9dcc06d"
integrity sha512-xdQnfykZ9JMEiasTAJZJdMWCQ1Vm00NBw79/AWi7ELfZuuPCSOMDZbT9mkOfSctVtfhb+sAAzrm+j//GjjLHLg==
version "1.16.3"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.16.3.tgz#52490d978f96ff9f89ec15b5cf244304a5bca161"
integrity sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw==
dependencies:
es-to-primitive "^1.2.0"
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
has "^1.0.3"
has-symbols "^1.0.0"
has-symbols "^1.0.1"
is-callable "^1.1.4"
is-regex "^1.0.4"
object-inspect "^1.6.0"
object-inspect "^1.7.0"
object-keys "^1.1.1"
string.prototype.trimleft "^2.1.0"
string.prototype.trimright "^2.1.0"
es-to-primitive@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377"
integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==
es-to-primitive@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
dependencies:
is-callable "^1.1.4"
is-date-object "^1.0.1"
@ -3837,10 +3837,10 @@ grunt@1.0.4:
path-is-absolute "~1.0.0"
rimraf "~2.6.2"
gscan@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/gscan/-/gscan-3.1.1.tgz#e86c2b0f93df7b1f85452584d3dbcb0b766bf901"
integrity sha512-As5E9ghdLfEmp+JjFcZhkkPNPzsLNgMj0r/CVkgwcHcQ1rTu4WhLKH3FsZIZPObCwqWjNxJeMTU+Tf4hrDKXfw==
gscan@3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/gscan/-/gscan-3.2.0.tgz#c3d237bd1db5df35fab9155191ede6caf05755b6"
integrity sha512-6M/Vtw9ko732zESJBhVforEFiCM6pbSMX6FGjOCf4NGYiEYB00LaHNczaxbhNuEa25usRGexM2AO9vFM3uP4TQ==
dependencies:
"@tryghost/extract-zip" "1.6.6"
"@tryghost/pretty-cli" "1.2.2"
@ -3925,10 +3925,10 @@ has-flag@^3.0.0:
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
has-symbols@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44"
integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=
has-symbols@^1.0.0, has-symbols@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
has-unicode@^2.0.0:
version "2.0.1"
@ -4588,11 +4588,11 @@ is-svg@^2.0.0:
html-comment-regex "^1.1.0"
is-symbol@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38"
integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==
version "1.0.3"
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
dependencies:
has-symbols "^1.0.0"
has-symbols "^1.0.1"
is-typedarray@~1.0.0:
version "1.0.0"
@ -6322,10 +6322,10 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-inspect@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.6.0.tgz#c70b6cbf72f274aab4c34c0c82f5167bf82cf15b"
integrity sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==
object-inspect@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67"
integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==
object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
version "1.1.1"