Fixed subscription status not showing correctly in emails

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

- The segment detection doesn't work outside the main post content. So the data-gh-segment attribute didn't work. It is now replaced with just a simple email replacement that is empty for a free member.
- Fixed that a trialing member was shown as 'paid'. This is now replaced with 'trialing'.

This commit also includes E2E tests for a couple of member statusses.
This commit is contained in:
Simon Backx 2023-03-24 08:51:20 +01:00
parent eb1d63eac0
commit 0107d2bb77
6 changed files with 4159 additions and 17 deletions

View File

@ -13,7 +13,7 @@ const {settingsCache} = require('../../../../core/server/services/settings-helpe
const DomainEvents = require('@tryghost/domain-events');
const emailService = require('../../../../core/server/services/email-service');
const should = require('should');
const {mockSetting} = require('../../../utils/e2e-framework-mock-manager');
const {mockSetting, stripeMocker} = require('../../../utils/e2e-framework-mock-manager');
const mobileDocExample = '{"version":"0.3.1","atoms":[],"cards":[],"markups":[],"sections":[[1,"p",[[0,[],0,"Hello world"]]]],"ghostVersion":"4.0"}';
const mobileDocWithPaywall = '{"version":"0.3.1","markups":[],"atoms":[],"cards":[["paywall",{}]],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members content"]]]]}';
@ -263,6 +263,7 @@ describe('Batch sending tests', function () {
// Allows for setting stubbedSend during tests
return stubbedSend.call(this, ...arguments);
});
mockManager.mockStripe();
});
afterEach(async function () {
@ -1039,19 +1040,189 @@ describe('Batch sending tests', function () {
await models.Newsletter.edit({show_comment_cta: true}, {id: defaultNewsletter.id});
});
it('Shows subscription details box', async function () {
it('Shows subscription details box for free members', async function () {
// Create a new member without a first_name
await models.Member.add({
email: 'subscription-box-1@example.com',
labels: [{name: 'subscription-box-tests'}],
newsletters: [{
id: fixtureManager.get('newsletters', 0).id
}]
});
mockSetting('email_track_clicks', false); // Disable link replacement for this test
const defaultNewsletter = await getDefaultNewsletter();
await models.Newsletter.edit({show_subscription_details: true}, {id: defaultNewsletter.id});
const {html} = await sendEmail({
const {html, plaintext} = await sendEmail({
title: 'This is a test post title',
mobiledoc: mobileDocExample
});
}, 'label:subscription-box-tests');
// Currently the link is not present in plaintext version (because no text)
assert.equal(html.match(/#\/portal\/account/g).length, 1, 'Subscription details box should contain a link to the account page');
// Check text matches
assert.match(plaintext, /You are receiving this because you are a free subscriber to Ghost\./);
await lastEmailMatchSnapshot();
// undo
await models.Newsletter.edit({show_subscription_details: false}, {id: defaultNewsletter.id});
});
it('Shows subscription details box for comped members', async function () {
// Create a new member without a first_name
await models.Member.add({
email: 'subscription-box-comped@example.com',
labels: [{name: 'subscription-box-comped-tests'}],
newsletters: [{
id: fixtureManager.get('newsletters', 0).id
}],
status: 'comped'
});
mockSetting('email_track_clicks', false); // Disable link replacement for this test
const defaultNewsletter = await getDefaultNewsletter();
await models.Newsletter.edit({show_subscription_details: true}, {id: defaultNewsletter.id});
const {html, plaintext} = await sendEmail({
title: 'This is a test post title',
mobiledoc: mobileDocExample
}, 'label:subscription-box-comped-tests');
// Currently the link is not present in plaintext version (because no text)
assert.equal(html.match(/#\/portal\/account/g).length, 1, 'Subscription details box should contain a link to the account page');
// Check text matches
assert.match(plaintext, /You are receiving this because you are a complimentary subscriber to Ghost\./);
await lastEmailMatchSnapshot();
// undo
await models.Newsletter.edit({show_subscription_details: false}, {id: defaultNewsletter.id});
});
it('Shows subscription details box for trialing member', async function () {
mockSetting('email_track_clicks', false); // Disable link replacement for this test
// Create a new member without a first_name
const customer = stripeMocker.createCustomer({
email: 'trialing-paid@example.com'
});
const price = await stripeMocker.getPriceForTier('default-product', 'month');
await stripeMocker.createTrialSubscription({
customer,
price
});
const member = await models.Member.findOne({email: customer.email}, {require: true});
await models.Member.edit({
labels: [{name: 'subscription-box-trialing-tests'}],
newsletters: [{
id: fixtureManager.get('newsletters', 0).id
}]
}, {id: member.id});
const defaultNewsletter = await getDefaultNewsletter();
await models.Newsletter.edit({show_subscription_details: true}, {id: defaultNewsletter.id});
const {html, plaintext} = await sendEmail({
title: 'This is a test post title',
mobiledoc: mobileDocExample
}, 'label:subscription-box-trialing-tests');
// Currently the link is not present in plaintext version (because no text)
assert.equal(html.match(/#\/portal\/account/g).length, 1, 'Subscription details box should contain a link to the account page');
// Check text matches
assert.match(plaintext, /You are receiving this because you are a trialing subscriber to Ghost\. Your free trial ends on \d+ \w+ \d+, at which time you will be charged the regular price\. You can always cancel before then\./);
await lastEmailMatchSnapshot();
// undo
await models.Newsletter.edit({show_subscription_details: false}, {id: defaultNewsletter.id});
});
it('Shows subscription details box for paid member', async function () {
mockSetting('email_track_clicks', false); // Disable link replacement for this test
// Create a new member without a first_name
const customer = stripeMocker.createCustomer({
email: 'paid@example.com'
});
const price = await stripeMocker.getPriceForTier('default-product', 'month');
await stripeMocker.createSubscription({
customer,
price
});
const member = await models.Member.findOne({email: customer.email}, {require: true});
await models.Member.edit({
labels: [{name: 'subscription-box-paid-tests'}],
newsletters: [{
id: fixtureManager.get('newsletters', 0).id
}]
}, {id: member.id});
const defaultNewsletter = await getDefaultNewsletter();
await models.Newsletter.edit({show_subscription_details: true}, {id: defaultNewsletter.id});
const {html, plaintext} = await sendEmail({
title: 'This is a test post title',
mobiledoc: mobileDocExample
}, 'label:subscription-box-paid-tests');
// Currently the link is not present in plaintext version (because no text)
assert.equal(html.match(/#\/portal\/account/g).length, 1, 'Subscription details box should contain a link to the account page');
// Check text matches
assert.match(plaintext, /You are receiving this because you are a paid subscriber to Ghost\. Your subscription will renew on \d+ \w+ \d+\./);
await lastEmailMatchSnapshot();
// undo
await models.Newsletter.edit({show_subscription_details: false}, {id: defaultNewsletter.id});
});
it('Shows subscription details box for canceled paid member', async function () {
mockSetting('email_track_clicks', false); // Disable link replacement for this test
// Create a new member without a first_name
const customer = stripeMocker.createCustomer({
email: 'canceled-paid@example.com'
});
const price = await stripeMocker.getPriceForTier('default-product', 'month');
await stripeMocker.createSubscription({
customer,
price,
cancel_at_period_end: true
});
const member = await models.Member.findOne({email: customer.email}, {require: true});
await models.Member.edit({
labels: [{name: 'subscription-box-canceled-tests'}],
newsletters: [{
id: fixtureManager.get('newsletters', 0).id
}]
}, {id: member.id});
const defaultNewsletter = await getDefaultNewsletter();
await models.Newsletter.edit({show_subscription_details: true}, {id: defaultNewsletter.id});
const {html, plaintext} = await sendEmail({
title: 'This is a test post title',
mobiledoc: mobileDocExample
}, 'label:subscription-box-canceled-tests');
// Currently the link is not present in plaintext version (because no text)
assert.equal(html.match(/#\/portal\/account/g).length, 1, 'Subscription details box should contain a link to the account page');
// Check text matches
assert.match(plaintext, /You are receiving this because you are a paid subscriber to Ghost\. Your subscription has been canceled and will expire on \d+ \w+ \d+\. You can resume your subscription via your account settings\./);
await lastEmailMatchSnapshot();
// undo

View File

@ -85,7 +85,8 @@ class StripeMocker {
customer,
price,
status: 'trialing',
trial_end_at: (Date.now() + 1000 * 60 * 60 * 24 * 7) / 1000,
trial_start: Date.now() / 1000,
trial_end: (Date.now() + 1000 * 60 * 60 * 24 * 7) / 1000,
...overrides
});
}

View File

@ -11,7 +11,7 @@ const tpl = require('@tryghost/tpl');
const messages = {
subscriptionStatus: {
free: 'You are currently subscribed to the free plan.',
free: '',
expired: 'Your subscription has expired.',
canceled: 'Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.',
active: 'Your subscription will renew on {date}.',
@ -405,6 +405,29 @@ class EmailRenderer {
return url.href;
}
/**
* Returns whether a paid member is trialing a subscription
*/
isMemberTrialing(member) {
// Do we have an active subscription?
if (member.status === 'paid') {
let activeSubscription = member.subscriptions.find((subscription) => {
return subscription.status === 'trialing';
});
if (!activeSubscription) {
return false;
}
// Translate to a human readable string
if (activeSubscription.trial_end_at && activeSubscription.trial_end_at > new Date() && activeSubscription.status === 'trialing') {
return true;
}
}
return false;
}
/**
* @param {MemberLike} member
* @returns {string}
@ -528,6 +551,9 @@ class EmailRenderer {
if (member.status === 'comped') {
return 'complimentary';
}
if (this.isMemberTrialing(member)) {
return 'trialing';
}
return member.status;
}
},

View File

@ -180,8 +180,7 @@
<td class="subscription-box">
<h3>Subscription details</h3>
<p style="margin-bottom: 16px;">
<span>You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {{site.title}}.</span>
<span data-gh-segment="status:-free">%%{status_text}%%</span>
<span>You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {{site.title}}.</span> %%{status_text}%%
</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>

View File

@ -203,6 +203,24 @@ describe('Email renderer', function () {
assert.equal(replacements[0].getValue(member), 'complimentary');
});
it('returns mapped trialing status', function () {
member.status = 'paid';
member.subscriptions = [
{
status: 'trialing',
trial_end_at: new Date(2050, 2, 13, 12, 0),
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: false
}
];
const html = 'Hello %%{status}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
assert.equal(replacements.length, 1);
assert.equal(replacements[0].token.toString(), '/%%\\{status\\}%%/g');
assert.equal(replacements[0].id, 'status');
assert.equal(replacements[0].getValue(member), 'trialing');
});
it('returns manage_account_url', function () {
const html = 'Hello %%{manage_account_url}%%,';
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
@ -214,11 +232,21 @@ describe('Email renderer', function () {
it('returns status_text', function () {
const html = 'Hello %%{status_text}%%,';
member.status = 'paid';
member.subscriptions = [
{
status: 'trialing',
trial_end_at: new Date(2050, 2, 13, 12, 0),
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: false
}
];
const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
assert.equal(replacements.length, 1);
assert.equal(replacements[0].token.toString(), '/%%\\{status_text\\}%%/g');
assert.equal(replacements[0].id, 'status_text');
assert.equal(replacements[0].getValue(member), 'You are currently subscribed to the free plan.');
assert.equal(replacements[0].getValue(member), 'Your free trial ends on 13 March 2050, at which time you will be charged the regular price. You can always cancel before then.');
});
it('returns correct createdAt', function () {
@ -279,6 +307,109 @@ describe('Email renderer', function () {
});
});
describe('isMemberTrialing', function () {
let emailRenderer;
beforeEach(function () {
emailRenderer = new EmailRenderer({
urlUtils: {
urlFor: () => 'http://example.com/subdirectory/'
},
labs: {
isSet: () => true
},
settingsCache: {
get: (key) => {
if (key === 'timezone') {
return 'UTC';
}
}
}
});
});
it('Returns false for free member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'free'
};
const result = emailRenderer.isMemberTrialing(member);
assert.equal(result, false);
});
it('Returns false for paid member without trial', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [
{
status: 'active',
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: false
}
]
};
const result = emailRenderer.isMemberTrialing(member);
assert.equal(result, false);
});
it('Returns true for trialing paid member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [
{
status: 'trialing',
trial_end_at: new Date(2050, 2, 13, 12, 0),
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: false
}
],
tiers: []
};
const result = emailRenderer.isMemberTrialing(member);
assert.equal(result, true);
});
it('Returns false for expired trialing paid member', function () {
const member = {
id: '456',
uuid: 'myuuid',
name: 'Test User',
email: 'test@example.com',
createdAt: new Date(2023, 2, 13, 12, 0),
status: 'paid',
subscriptions: [
{
status: 'trialing',
trial_end_at: new Date(2000, 2, 13, 12, 0),
current_period_end: new Date(2023, 2, 13, 12, 0),
cancel_at_period_end: false
}
],
tiers: []
};
const result = emailRenderer.isMemberTrialing(member);
assert.equal(result, false);
});
});
describe('getMemberStatusText', function () {
let emailRenderer;
@ -311,7 +442,7 @@ describe('Email renderer', function () {
};
const result = emailRenderer.getMemberStatusText(member);
assert.equal(result, 'You are currently subscribed to the free plan.');
assert.equal(result, '');
});
it('Returns for active paid member', function () {