Skip to content
Merged
9 changes: 3 additions & 6 deletions ghost/admin/app/helpers/parse-member-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export default class ParseMemberEventHelper extends Helper {
}

if (event.type === 'gift_ended_event') {
icon = 'subscriptions';
icon = 'expired-gift';
}

if (event.type === 'email_change_event') {
Expand Down Expand Up @@ -187,9 +187,6 @@ export default class ParseMemberEventHelper extends Helper {

if (event.type === 'subscription_event') {
if (event.data.type === 'created') {
if (event.data.previous_status === 'gift') {
return 'continued paid subscription after gift';
}
return 'started paid subscription';
}
if (event.data.type === 'updated') {
Expand Down Expand Up @@ -281,11 +278,11 @@ export default class ParseMemberEventHelper extends Helper {
}

if (event.type === 'gift_redemption_event') {
return 'started paid subscription via gift';
return 'started gift subscription';
}

if (event.type === 'gift_ended_event') {
return 'ended paid subscription';
return 'gift subscription expired';
}
}

Expand Down
4 changes: 4 additions & 0 deletions ghost/admin/public/assets/icons/event-expired-gift.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 19 additions & 32 deletions ghost/admin/tests/unit/helpers/parse-member-event-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,34 +30,7 @@ describe('Unit: Helper: parse-member-event', function () {
});

describe('subscription_event action', function () {
it('returns "continued paid subscription after gift" when previous_status is "gift"', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created', previous_status: 'gift'}
});
const result = helper.compute([event]);
expect(result.action).to.equal('continued paid subscription after gift');
});

it('returns "started paid subscription" when previous_status is null', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created', previous_status: null}
});
const result = helper.compute([event]);
expect(result.action).to.equal('started paid subscription');
});

it('returns "started paid subscription" when previous_status is "free"', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created', previous_status: 'free'}
});
const result = helper.compute([event]);
expect(result.action).to.equal('started paid subscription');
});

it('returns "started paid subscription" when previous_status is missing', function () {
it('returns "started paid subscription" for a created subscription_event', function () {
const event = buildEvent({
type: 'subscription_event',
data: {type: 'created'}
Expand Down Expand Up @@ -114,17 +87,31 @@ describe('Unit: Helper: parse-member-event', function () {
});
});

describe('gift_redemption_event', function () {
it('returns "started gift subscription" action', function () {
const event = buildEvent({type: 'gift_redemption_event'});
const result = helper.compute([event]);
expect(result.action).to.equal('started gift subscription');
});

it('returns "event-gift" icon', function () {
const event = buildEvent({type: 'gift_redemption_event'});
const result = helper.compute([event]);
expect(result.icon).to.equal('event-gift');
});
});

describe('gift_ended_event', function () {
it('returns "ended paid subscription" action', function () {
it('returns "gift subscription expired" action', function () {
const event = buildEvent({type: 'gift_ended_event'});
const result = helper.compute([event]);
expect(result.action).to.equal('ended paid subscription');
expect(result.action).to.equal('gift subscription expired');
});

it('returns "event-subscriptions" icon', function () {
it('returns "event-expired-gift" icon', function () {
const event = buildEvent({type: 'gift_ended_event'});
const result = helper.compute([event]);
expect(result.icon).to.equal('event-subscriptions');
expect(result.icon).to.equal('event-expired-gift');
});
});
});
4 changes: 2 additions & 2 deletions ghost/core/core/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ async function initServices() {
const statsService = require('./server/services/stats');
const explorePingService = require('./server/services/explore-ping');
const domainEvents = require('@tryghost/domain-events');
const WelcomeEmailAutomationsService = require('./server/services/welcome-email-automations');
const AutomationsService = require('./server/services/automations');

const {
createAdapter: createSchedulerAdapter,
Expand Down Expand Up @@ -400,7 +400,7 @@ async function initServices() {
schedulerAdapter,
schedulerIntegration
}),
new WelcomeEmailAutomationsService().init({
new AutomationsService().init({
domainEvents,
apiUrl,
schedulerAdapter,
Expand Down
3 changes: 1 addition & 2 deletions ghost/core/core/frontend/utils/images.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
const url = require('url');
const imageTransform = require('@tryghost/image-transform');
const urlUtils = require('../../shared/url-utils');
const storageUtils = require('../../server/adapters/storage/utils');
Expand All @@ -11,7 +10,7 @@ module.exports.detectInternalImage = function detectInternalImage(requestedImage
// CASE: imagePath is a "protocol relative" url e.g. "//www.gravatar.com/ava..."
// by resolving the the imagePath relative to the blog url, we can then
// detect if the imagePath is external, or internal.
const isRelativeInternalImage = !isAbsoluteImage && url.resolve(siteUrl, requestedImageUrl).startsWith(siteUrl);
const isRelativeInternalImage = !isAbsoluteImage && new URL(requestedImageUrl, siteUrl).toString().startsWith(siteUrl);

return isAbsoluteInternalImage || isRelativeInternalImage;
};
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/core/server/api/endpoints/automations.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const domainEvents = require('@tryghost/domain-events');
const models = require('../../models');
const StartAutomationsPollEvent = require('../../services/welcome-email-automations/events/start-automations-poll-event');
const StartAutomationsPollEvent = require('../../services/automations/events/start-automations-poll-event');

/** @type {import('@tryghost/api-framework').Controller} */
const controller = {
Expand Down
3 changes: 1 addition & 2 deletions ghost/core/core/server/services/auth/members/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ const config = require('../../../../shared/config');
let UNO_MEMBERINO;

async function createMiddleware() {
const url = require('url');
const {protocol, host} = url.parse(config.get('url'));
const {protocol, host} = new URL(config.get('url'));
const siteOrigin = `${protocol}//${host}`;

const membersConfig = await membersService.api.getPublicConfig();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const memberWelcomeEmailService = require('../member-welcome-emails/service');
* }>} api_keys
*/

class WelcomeEmailAutomationsService {
class AutomationsService {
#initialized = false;

/**
Expand Down Expand Up @@ -82,4 +82,4 @@ class WelcomeEmailAutomationsService {
}
}

module.exports = WelcomeEmailAutomationsService;
module.exports = AutomationsService;
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@ module.exports = class EventRepository {
'subscriptionCreatedEvent.userAttribution',
'subscriptionCreatedEvent.tagAttribution',
'subscriptionCreatedEvent.memberCreatedEvent',
'subscriptionCreatedEvent.paidStatusEvent',

// This is rediculous, but we need the tier name (we'll be able to shorten this later when we switch to the subscriptions table)
'stripeSubscription.stripePrice.stripeProduct.product'
Expand Down Expand Up @@ -248,22 +247,16 @@ module.exports = class EventRepository {
const tierName = model.related('stripeSubscription') && model.related('stripeSubscription').related('stripePrice') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct') && model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product') ? model.related('stripeSubscription').related('stripePrice').related('stripeProduct').related('product').get('name') : null;

const subscriptionCreatedEvent = model.related('subscriptionCreatedEvent');
const paidStatusEvent = subscriptionCreatedEvent && subscriptionCreatedEvent.id
? subscriptionCreatedEvent.related('paidStatusEvent')
: null;
const previousStatus = paidStatusEvent && paidStatusEvent.id ? paidStatusEvent.get('from_status') : null;

// Prevent toJSON on stripeSubscription (we don't have everything loaded)
delete model.relations.stripeSubscription;
const d = {
...model.toJSON(options),
attribution: model.get('type') === 'created' && subscriptionCreatedEvent && subscriptionCreatedEvent.id ? this._memberAttributionService.getEventAttribution(subscriptionCreatedEvent) : null,
signup: model.get('type') === 'created' && subscriptionCreatedEvent && subscriptionCreatedEvent.id && subscriptionCreatedEvent.related('memberCreatedEvent') && subscriptionCreatedEvent.related('memberCreatedEvent').id ? true : false,
previous_status: previousStatus,
tierName
};
delete d.stripeSubscription;
delete d.subscriptionCreatedEvent?.paidStatusEvent;
return {
type: 'subscription_event',
data: d
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const {NotFoundError} = require('@tryghost/errors');
const validator = require('@tryghost/validator');
const crypto = require('crypto');
const hasActiveOffer = require('../utils/has-active-offer');
const StartAutomationsPollEvent = require('../../../welcome-email-automations/events/start-automations-poll-event');
const StartAutomationsPollEvent = require('../../../automations/events/start-automations-poll-event');
const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../../member-welcome-emails/constants');

const messages = {
Expand Down Expand Up @@ -1954,15 +1954,15 @@ module.exports = class MemberRepository {
});
}

const zeroValuePrices = defaultProduct.stripePrices.filter((price) => {
return price.amount === 0;
const complimentaryPrices = defaultProduct.stripePrices.filter((price) => {
return price.amount === 0 && this.isComplimentaryPlanNickname(price.nickname);
});

if (activeSubscriptions.length) {
for (const subscription of activeSubscriptions) {
const price = await subscription.related('stripePrice').fetch(options);

let zeroValuePrice = zeroValuePrices.find((p) => {
let zeroValuePrice = complimentaryPrices.find((p) => {
return p.currency.toLowerCase() === price.get('currency').toLowerCase();
});

Expand All @@ -1980,9 +1980,14 @@ module.exports = class MemberRepository {
}]
}, options)).toJSON();
zeroValuePrice = product.stripePrices.find((p) => {
return p.currency.toLowerCase() === price.get('currency').toLowerCase() && p.amount === 0;
return p.currency.toLowerCase() === price.get('currency').toLowerCase() && p.amount === 0 && this.isComplimentaryPlanNickname(p.nickname);
});
zeroValuePrices.push(zeroValuePrice);
if (!zeroValuePrice) {
throw new errors.NotFoundError({
message: `Failed to locate a complimentary (zero-amount, nickname matched by isComplimentaryPlanNickname) Stripe price for currency "${price.get('currency')}" on product ${product.id} after update. Returned stripePrices: ${JSON.stringify(product.stripePrices)}`
});
}
complimentaryPrices.push(zeroValuePrice);
}

const stripeSubscription = await this._stripeAPIService.getSubscription(
Expand Down Expand Up @@ -2014,7 +2019,7 @@ module.exports = class MemberRepository {
name: stripeCustomer.name
}, sharedOptions);

let zeroValuePrice = zeroValuePrices[0];
let zeroValuePrice = complimentaryPrices[0];

if (!zeroValuePrice) {
const product = (await this._productRepository.update({
Expand All @@ -2030,9 +2035,14 @@ module.exports = class MemberRepository {
}]
}, sharedOptions)).toJSON();
zeroValuePrice = product.stripePrices.find((price) => {
return price.currency.toLowerCase() === 'usd' && price.amount === 0;
return price.currency.toLowerCase() === 'usd' && price.amount === 0 && this.isComplimentaryPlanNickname(price.nickname);
});
zeroValuePrices.push(zeroValuePrice);
if (!zeroValuePrice) {
throw new errors.NotFoundError({
message: `Failed to locate a complimentary (zero-amount, nickname matched by isComplimentaryPlanNickname) Stripe price for currency "USD" on product ${product.id} after update. Returned stripePrices: ${JSON.stringify(product.stripePrices)}`
});
}
complimentaryPrices.push(zeroValuePrice);
}

const subscription = await this._stripeAPIService.createSubscription(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<!-- START CENTERED CONTAINER -->
{{#> preview}}
{{#*inline "content"}}
{{tierData.name}}: {{tierData.details}} {{#if offerData}}- Offer: {{offerData.name}} - {{offerData.details}}{{/if}}
{{tierData.name}}: {{tierData.details}}{{#if tierData.trialDays}} - {{tierData.trialDays}} days free{{/if}} {{#if offerData}}- Offer: {{offerData.name}} - {{offerData.details}}{{/if}}
{{/inline}}
{{/preview}}

Expand Down Expand Up @@ -44,7 +44,7 @@
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Name</p>
<p class="text-link large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 400;">{{memberData.name}}{{#if memberData.showEmail}} ({{memberData.email}}){{/if}}</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Tier</p>
<p class="text-link large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 400;">{{tierData.name}}{{#if tierData.details}} &bull; {{tierData.details}}{{/if}}</p>
<p class="text-link large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 400;">{{tierData.name}}{{#if tierData.details}} &bull; {{tierData.details}}{{/if}}{{#if tierData.trialDays}} &bull; <span style="color: {{accentColor}};">{{tierData.trialDays}} days free</span>{{/if}}</p>
{{#if offerData}}
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; margin: 0; padding-bottom: 4px; color: #15171A; font-weight: 700;">Offer</p>
<p class="text-link large" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; margin: 0; padding-bottom: 24px; color: #15171A; font-weight: 400;">{{offerData.name}} &bull; <span style="color: {{accentColor}};">{{offerData.details}}</span></p>
Expand Down
12 changes: 11 additions & 1 deletion ghost/core/core/server/services/staff/staff-service-emails.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ class StaffServiceEmails {
const interval = subscription?.interval || '';
const tierData = {
name: tier?.name || '',
details: `${formattedAmount}/${interval}`
details: `${formattedAmount}/${interval}`,
trialDays: null
};

const subscriptionData = {
Expand All @@ -88,6 +89,15 @@ class StaffServiceEmails {

let offerData = this.getOfferData(offer);

if (!offerData && subscription?.trialEnd) {
const trialEnd = moment(subscription.trialEnd);
const trialStart = subscription.trialStart ? moment(subscription.trialStart) : moment();
const days = trialEnd.diff(trialStart, 'days');
if (days > 0) {
tierData.trialDays = days;
}
}

let attributionTitle = attribution?.title || '';
// In case of a homepage attribution, we want to show the title as "Homepage" on email
if (attributionTitle === 'homepage') {
Expand Down
4 changes: 3 additions & 1 deletion ghost/core/core/server/services/staff/staff-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ class StaffService {
currency: subscription.plan?.currency,
startDate: subscription.start_date,
cancelAt: subscription.current_period_end,
cancellationReason: subscription.cancellation_reason
cancellationReason: subscription.cancellation_reason,
trialStart: subscription.trial_start_at,
trialEnd: subscription.trial_end_at
} : null,
member: member ? {
id: member.id,
Expand Down
8 changes: 3 additions & 5 deletions ghost/core/core/server/web/api/middleware/cors.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function corsOptionsDelegate(req, cb) {
* @param {Express.Response} res
* @param {Function} next
*/
const handleCaching = function handleCaching(req, res, next) {
const corsCaching = function corsCaching(req, res, next) {
const method = req.method && req.method.toUpperCase && req.method.toUpperCase();
if (method === 'OPTIONS') {
// @NOTE: try to add native support for dynamic 'vary' header value in 'cors' module
Expand All @@ -96,7 +96,5 @@ const handleCaching = function handleCaching(req, res, next) {
next();
};

module.exports = [
handleCaching,
cors(corsOptionsDelegate)
];
exports.corsMiddleware = cors(corsOptionsDelegate);
exports.corsCaching = corsCaching;
7 changes: 6 additions & 1 deletion ghost/core/core/server/web/api/middleware/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
const {corsCaching, corsMiddleware} = require('./cors');

module.exports = {
cors: require('./cors'),
cors: [
corsCaching,
corsMiddleware
],
updateUserLastSeen: require('./update-user-last-seen'),
upload: require('./upload')
};
Loading