diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index 91a9ddcc22f..fcc8d563a37 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -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') { @@ -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') { @@ -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'; } } diff --git a/ghost/admin/public/assets/icons/event-expired-gift.svg b/ghost/admin/public/assets/icons/event-expired-gift.svg new file mode 100644 index 00000000000..fe6eb1c5807 --- /dev/null +++ b/ghost/admin/public/assets/icons/event-expired-gift.svg @@ -0,0 +1,4 @@ + diff --git a/ghost/admin/tests/unit/helpers/parse-member-event-test.js b/ghost/admin/tests/unit/helpers/parse-member-event-test.js index cef76c440b3..c95c608fc98 100644 --- a/ghost/admin/tests/unit/helpers/parse-member-event-test.js +++ b/ghost/admin/tests/unit/helpers/parse-member-event-test.js @@ -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'} @@ -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'); }); }); }); diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index f942b884407..723b40c0548 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -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, @@ -400,7 +400,7 @@ async function initServices() { schedulerAdapter, schedulerIntegration }), - new WelcomeEmailAutomationsService().init({ + new AutomationsService().init({ domainEvents, apiUrl, schedulerAdapter, diff --git a/ghost/core/core/frontend/utils/images.js b/ghost/core/core/frontend/utils/images.js index 3caa5ce057b..9b8f587b71d 100644 --- a/ghost/core/core/frontend/utils/images.js +++ b/ghost/core/core/frontend/utils/images.js @@ -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'); @@ -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; }; diff --git a/ghost/core/core/server/api/endpoints/automations.js b/ghost/core/core/server/api/endpoints/automations.js index 30c6cff71b8..663249659fd 100644 --- a/ghost/core/core/server/api/endpoints/automations.js +++ b/ghost/core/core/server/api/endpoints/automations.js @@ -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 = { diff --git a/ghost/core/core/server/services/auth/members/index.js b/ghost/core/core/server/services/auth/members/index.js index 9f7eaa98a82..fa29c79c274 100644 --- a/ghost/core/core/server/services/auth/members/index.js +++ b/ghost/core/core/server/services/auth/members/index.js @@ -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(); diff --git a/ghost/core/core/server/services/welcome-email-automations/events/start-automations-poll-event.js b/ghost/core/core/server/services/automations/events/start-automations-poll-event.js similarity index 100% rename from ghost/core/core/server/services/welcome-email-automations/events/start-automations-poll-event.js rename to ghost/core/core/server/services/automations/events/start-automations-poll-event.js diff --git a/ghost/core/core/server/services/welcome-email-automations/index.js b/ghost/core/core/server/services/automations/index.js similarity index 96% rename from ghost/core/core/server/services/welcome-email-automations/index.js rename to ghost/core/core/server/services/automations/index.js index 5761ba6111d..72e99f07717 100644 --- a/ghost/core/core/server/services/welcome-email-automations/index.js +++ b/ghost/core/core/server/services/automations/index.js @@ -28,7 +28,7 @@ const memberWelcomeEmailService = require('../member-welcome-emails/service'); * }>} api_keys */ -class WelcomeEmailAutomationsService { +class AutomationsService { #initialized = false; /** @@ -82,4 +82,4 @@ class WelcomeEmailAutomationsService { } } -module.exports = WelcomeEmailAutomationsService; +module.exports = AutomationsService; diff --git a/ghost/core/core/server/services/welcome-email-automations/poll.js b/ghost/core/core/server/services/automations/poll.js similarity index 100% rename from ghost/core/core/server/services/welcome-email-automations/poll.js rename to ghost/core/core/server/services/automations/poll.js diff --git a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js index 2f8f8ec4b5b..e8fa01cd5d3 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/event-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/event-repository.js @@ -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' @@ -248,10 +247,6 @@ 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; @@ -259,11 +254,9 @@ module.exports = class EventRepository { ...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 diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index 1388400decf..4db186a65a3 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -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 = { @@ -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(); }); @@ -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( @@ -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({ @@ -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( diff --git a/ghost/core/core/server/services/staff/email-templates/new-paid-started.hbs b/ghost/core/core/server/services/staff/email-templates/new-paid-started.hbs index 5086674f6da..b01fef4a912 100644 --- a/ghost/core/core/server/services/staff/email-templates/new-paid-started.hbs +++ b/ghost/core/core/server/services/staff/email-templates/new-paid-started.hbs @@ -16,7 +16,7 @@ {{#> 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}} @@ -44,7 +44,7 @@
Name
{{memberData.name}}{{#if memberData.showEmail}} ({{memberData.email}}){{/if}}
Tier
-{{tierData.name}}{{#if tierData.details}} • {{tierData.details}}{{/if}}
+{{tierData.name}}{{#if tierData.details}} • {{tierData.details}}{{/if}}{{#if tierData.trialDays}} • {{tierData.trialDays}} days free{{/if}}
{{#if offerData}}Offer
{{offerData.name}} • {{offerData.details}}
diff --git a/ghost/core/core/server/services/staff/staff-service-emails.js b/ghost/core/core/server/services/staff/staff-service-emails.js index 7be8b9858ae..667afc80cf0 100644 --- a/ghost/core/core/server/services/staff/staff-service-emails.js +++ b/ghost/core/core/server/services/staff/staff-service-emails.js @@ -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 = { @@ -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') { diff --git a/ghost/core/core/server/services/staff/staff-service.js b/ghost/core/core/server/services/staff/staff-service.js index 2a9f6e24b20..6c85918224f 100644 --- a/ghost/core/core/server/services/staff/staff-service.js +++ b/ghost/core/core/server/services/staff/staff-service.js @@ -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, diff --git a/ghost/core/core/server/web/api/middleware/cors.js b/ghost/core/core/server/web/api/middleware/cors.js index 1852b27142c..64f0a7fe4e1 100644 --- a/ghost/core/core/server/web/api/middleware/cors.js +++ b/ghost/core/core/server/web/api/middleware/cors.js @@ -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 @@ -96,7 +96,5 @@ const handleCaching = function handleCaching(req, res, next) { next(); }; -module.exports = [ - handleCaching, - cors(corsOptionsDelegate) -]; +exports.corsMiddleware = cors(corsOptionsDelegate); +exports.corsCaching = corsCaching; diff --git a/ghost/core/core/server/web/api/middleware/index.js b/ghost/core/core/server/web/api/middleware/index.js index a30445b4465..d6b1d3d982b 100644 --- a/ghost/core/core/server/web/api/middleware/index.js +++ b/ghost/core/core/server/web/api/middleware/index.js @@ -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') }; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap index c2e140b430e..649b9d7ae97 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -22687,7 +22687,7 @@ exports[`Activity Feed API Can filter events by post id 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "20329", + "content-length": StringMatching /\\\\d\\+/, "content-type": "application/json; charset=utf-8", "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "vary": "Accept-Version, Origin, Accept-Encoding", @@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18676", + "content-length": StringMatching /\\\\d\\+/, "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/activity-feed.test.js b/ghost/core/test/e2e-api/admin/activity-feed.test.js index 70d1214e512..422fc70bee9 100644 --- a/ghost/core/test/e2e-api/admin/activity-feed.test.js +++ b/ghost/core/test/e2e-api/admin/activity-feed.test.js @@ -453,7 +453,8 @@ describe('Activity Feed API', function () { .expectStatus(200) .matchHeaderSnapshot({ etag: anyEtag, - 'content-version': anyContentVersion + 'content-version': anyContentVersion, + 'content-length': anyContentLength // Depending on random conditions (ID generation) the order of events can change }) .matchBodySnapshot({ events: new Array(15).fill({ diff --git a/ghost/core/test/e2e-api/admin/automations.test.js b/ghost/core/test/e2e-api/admin/automations.test.js index 240d3162de9..1395d0a7652 100644 --- a/ghost/core/test/e2e-api/admin/automations.test.js +++ b/ghost/core/test/e2e-api/admin/automations.test.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict'); const models = require('../../../core/server/models'); const {getSignedAdminToken} = require('../../../core/server/adapters/scheduling/utils'); const {agentProvider, dbUtils, fixtureManager, matchers, assertions} = require('../../utils/e2e-framework'); -const StartAutomationsPollEvent = require('../../../core/server/services/welcome-email-automations/events/start-automations-poll-event'); +const StartAutomationsPollEvent = require('../../../core/server/services/automations/events/start-automations-poll-event'); const {anyContentVersion, anyEtag, anyErrorId} = matchers; const {cacheInvalidateHeaderNotSet} = assertions; diff --git a/ghost/core/test/e2e-api/content/tags.test.js b/ghost/core/test/e2e-api/content/tags.test.js index d6d80e07be7..5a6e1ba2490 100644 --- a/ghost/core/test/e2e-api/content/tags.test.js +++ b/ghost/core/test/e2e-api/content/tags.test.js @@ -2,7 +2,6 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../utils/assertions'); const supertest = require('supertest'); const _ = require('lodash'); -const url = require('url'); const configUtils = require('../../utils/config-utils'); const config = require('../../../core/shared/config'); const testUtils = require('../../utils'); @@ -50,9 +49,8 @@ describe('Tags Content API', function () { assert.equal(jsonResponse.tags[4].name, 'kitchen sink'); } - assertExists(res.body.tags[0].url); - assertExists(url.parse(res.body.tags[0].url).protocol); - assertExists(url.parse(res.body.tags[0].url).host); + assert(new URL(res.body.tags[0].url).protocol); + assert(new URL(res.body.tags[0].url).host); }); it('Can request tags with limit=all', async function () { diff --git a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap index 0270247a019..88326c99a72 100644 --- a/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap +++ b/ghost/core/test/e2e-api/members/__snapshots__/webhooks.test.js.snap @@ -490,7 +490,7 @@ exports[`Members API Member attribution Returns subscription created attribution Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "7287", + "content-length": "7037", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/integration/services/welcome-email-automations/poll.test.js b/ghost/core/test/integration/services/automations/poll.test.js similarity index 99% rename from ghost/core/test/integration/services/welcome-email-automations/poll.test.js rename to ghost/core/test/integration/services/automations/poll.test.js index 5466a4329ef..004258a037b 100644 --- a/ghost/core/test/integration/services/welcome-email-automations/poll.test.js +++ b/ghost/core/test/integration/services/automations/poll.test.js @@ -5,7 +5,7 @@ const ObjectId = require('bson-objectid').default; const sinon = require('sinon'); const testUtils = require('../../../utils'); -const {poll} = require('../../../../core/server/services/welcome-email-automations/poll'); +const {poll} = require('../../../../core/server/services/automations/poll'); const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../../../core/server/services/member-welcome-emails/constants'); const {Member, WelcomeEmailAutomationRun} = require('../../../../core/server/models'); @@ -13,7 +13,7 @@ const RETRY_DELAY_MS = 10 * 60 * 1000; const LOCK_TIMEOUT_MS = 30 * 60 * 1000; const MAX_RUNS_PER_BATCH = 100; -describe('welcome email automations poll', function () { +describe('automations poll', function () { let options; before(async function () { diff --git a/ghost/core/test/unit/api/endpoints/automations.test.js b/ghost/core/test/unit/api/endpoints/automations.test.js index f06b69a4aff..0664ebfb375 100644 --- a/ghost/core/test/unit/api/endpoints/automations.test.js +++ b/ghost/core/test/unit/api/endpoints/automations.test.js @@ -1,42 +1,15 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const domainEvents = require('@tryghost/domain-events'); -const models = require('../../../../core/server/models'); const automationsController = require('../../../../core/server/api/endpoints/automations'); -const StartAutomationsPollEvent = require('../../../../core/server/services/welcome-email-automations/events/start-automations-poll-event'); +const StartAutomationsPollEvent = require('../../../../core/server/services/automations/events/start-automations-poll-event'); describe('Automations controller', function () { - before(function () { - models.init(); - }); - - function createMockAutomation(id, name, slug, status) { - return { - get(key) { - switch (key) { - case 'id': - return id; - case 'name': - return name; - case 'slug': - return slug; - case 'status': - return status; - default: - throw new Error(`Unexpected field: ${key}`); - } - } - }; - } + // Other endpoints are tested in E2E tests. let dispatchStub; beforeEach(function () { - sinon.stub(models.WelcomeEmailAutomation, 'findAll').resolves([ - createMockAutomation('automation-id-1', 'Welcome Email (Free)', 'member-welcome-email-free', 'active'), - createMockAutomation('automation-id-2', 'Welcome Email (Premium)', 'member-welcome-email-premium', 'inactive') - ]); - dispatchStub = sinon.stub(domainEvents, 'dispatch'); }); @@ -44,65 +17,6 @@ describe('Automations controller', function () { sinon.restore(); }); - describe('browse', function () { - it('returns only id, name, slug, and status fields', async function () { - const result = await automationsController.browse.query({}); - - assert.deepEqual(result.data, [{ - id: 'automation-id-1', - name: 'Welcome Email (Free)', - slug: 'member-welcome-email-free', - status: 'active' - }, { - id: 'automation-id-2', - name: 'Welcome Email (Premium)', - slug: 'member-welcome-email-premium', - status: 'inactive' - }]); - }); - }); - - describe('read', function () { - it('returns a placeholder automation for the requested id', function () { - const result = automationsController.read.query({ - data: { - id: '67f3f3f3f3f3f3f3f3f3f3f3' - } - }); - - assert.deepEqual(result, { - id: '67f3f3f3f3f3f3f3f3f3f3f3', - slug: 'member-welcome-email-free', - name: 'Welcome email', - status: 'active', - created_at: '2026-05-05T00:00:00.000Z', - updated_at: '2026-05-05T00:00:00.000Z', - actions: [{ - id: '67f3f3f3f3f3f3f3f3f3f3f4', - type: 'delay', - data: { - delay_hours: 24 - } - }, { - id: '67f3f3f3f3f3f3f3f3f3f3f5', - type: 'send email', - data: { - email_subject: 'Welcome!', - email_lexical: '{"root":{"children":[]}}', - email_sender_name: null, - email_sender_email: null, - email_sender_reply_to: null, - email_design_setting_id: '680000000000000000000001' - } - }], - edges: [{ - source_action_id: '67f3f3f3f3f3f3f3f3f3f3f4', - target_action_id: '67f3f3f3f3f3f3f3f3f3f3f5' - }] - }); - }); - }); - describe('poll', function () { it('dispatches a StartAutomationsPollEvent', function () { const result = automationsController.poll.query({}); diff --git a/ghost/core/test/unit/server/services/welcome-email-automations/index.test.js b/ghost/core/test/unit/server/services/automations/index.test.js similarity index 83% rename from ghost/core/test/unit/server/services/welcome-email-automations/index.test.js rename to ghost/core/test/unit/server/services/automations/index.test.js index 993af8d8050..b4f8c143be2 100644 --- a/ghost/core/test/unit/server/services/welcome-email-automations/index.test.js +++ b/ghost/core/test/unit/server/services/automations/index.test.js @@ -1,9 +1,9 @@ const sinon = require('sinon'); -const WelcomeEmailAutomationsService = require('../../../../../core/server/services/welcome-email-automations'); -const StartAutomationsPollEvent = require('../../../../../core/server/services/welcome-email-automations/events/start-automations-poll-event'); +const AutomationsService = require('../../../../../core/server/services/automations'); +const StartAutomationsPollEvent = require('../../../../../core/server/services/automations/events/start-automations-poll-event'); -describe('WelcomeEmailAutomationsService', function () { +describe('AutomationsService', function () { let service; let domainEvents; let schedulerAdapter; @@ -11,7 +11,7 @@ describe('WelcomeEmailAutomationsService', function () { let initOptions; beforeEach(function () { - service = new WelcomeEmailAutomationsService(); + service = new AutomationsService(); domainEvents = { dispatch: sinon.stub(), subscribe: sinon.stub() diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js index 12b53e352c2..00671a001cd 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/event-repository.test.js @@ -756,118 +756,4 @@ describe('EventRepository', function () { assert.equal(event.data.member_id, 'member-xyz'); }); }); - - describe('getSubscriptionEvents', function () { - // Builds a Bookshelf-shaped mock that mirrors how the model is used inside - // getSubscriptionEvents, including the eager-load behaviour where multiple - // MemberPaidSubscriptionEvent rows that share a subscription_id receive the - // SAME SubscriptionCreatedEvent instance via .related(). - function buildModels({sharedSubscriptionCreatedEvent}) { - function makeRelated(map) { - return name => map[name] ?? {id: undefined, related: () => ({id: undefined})}; - } - - const sharedRelated = makeRelated({ - subscriptionCreatedEvent: sharedSubscriptionCreatedEvent, - stripeSubscription: {related: () => ({related: () => ({related: () => null})})} - }); - - const buildModel = (attrs) => { - const relations = { - subscriptionCreatedEvent: sharedSubscriptionCreatedEvent, - stripeSubscription: {related: () => ({related: () => ({related: () => null})})} - }; - return { - id: attrs.id, - relations, - related: name => relations[name] ?? sharedRelated(name), - get: key => attrs[key], - toJSON: () => { - const paidStatusEvent = sharedSubscriptionCreatedEvent.related('paidStatusEvent'); - - return { - ...attrs, - subscriptionCreatedEvent: { - id: sharedSubscriptionCreatedEvent.id, - paidStatusEvent: paidStatusEvent && paidStatusEvent.id ? { - id: paidStatusEvent.id, - from_status: paidStatusEvent.get('from_status'), - to_status: paidStatusEvent.get('to_status') - } : undefined - } - }; - } - }; - }; - - return [ - // Order matches findPage(order: 'created_at desc, id desc'): - // the newer "updated" row comes first, the original "created" row second. - buildModel({ - id: 'mpse-updated', - type: 'updated', - member_id: 'member1', - subscription_id: 'sub1', - created_at: '2026-05-05T18:21:31.000Z' - }), - buildModel({ - id: 'mpse-created', - type: 'created', - member_id: 'member1', - subscription_id: 'sub1', - created_at: '2026-05-05T15:49:44.000Z' - }) - ]; - } - - it('preserves previous_status on every row when multiple events share a subscription_id', async function () { - // One SubscriptionCreatedEvent shared across both paid-subscription rows - // (this is what Bookshelf's belongsTo eager-load gives us when the foreign - // key is duplicated). The paidStatusEvent on it represents the gift-to-paid - // transition. - const paidStatusEvent = { - id: 'mse-gift-to-paid', - get: key => ({from_status: 'gift', to_status: 'paid'}[key]) - }; - const sharedSubscriptionCreatedEvent = { - id: 'sce1', - relations: {paidStatusEvent, memberCreatedEvent: {id: undefined}}, - related(name) { - return this.relations[name] ?? {id: undefined}; - } - }; - - const models = buildModels({sharedSubscriptionCreatedEvent}); - const findPage = sinon.fake.resolves({ - data: models, - meta: {pagination: {total: models.length}} - }); - - const eventRepository = new EventRepository({ - EmailRecipient: null, - MemberSubscribeEvent: null, - MemberPaymentEvent: null, - MemberStatusEvent: null, - MemberLoginEvent: null, - MemberPaidSubscriptionEvent: {findPage}, - memberAttributionService: {getEventAttribution: () => null}, - labsService: null - }); - - const result = await eventRepository.getSubscriptionEvents({}, ''); - - assert.equal(result.data.length, 2); - const created = result.data.find(e => e.data.type === 'created'); - const updated = result.data.find(e => e.data.type === 'updated'); - - // The original `created` row should still report previous_status='gift' - // even when iterated AFTER another row that shares its SubscriptionCreatedEvent. - assert.equal(created.data.previous_status, 'gift'); - - // The helper relation should be removed from the serialized payload - assert.equal(created.data.subscriptionCreatedEvent.paidStatusEvent, undefined); - assert.equal(updated.data.subscriptionCreatedEvent.paidStatusEvent, undefined); - assert.equal(sharedSubscriptionCreatedEvent.related('paidStatusEvent').get('from_status'), 'gift'); - }); - }); }); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js index 531e4d1c855..17ffe68c6b1 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js @@ -143,6 +143,93 @@ describe('MemberRepository', function () { assert.equal(err.message, 'Could not find Product "default"'); } }); + + it('ignores non-complimentary zero-value prices when creating a complimentary subscription', async function () { + const member = { + id: 'member_id_123', + get: sinon.stub().withArgs('email').returns('member@example.com'), + related: () => { + return { + fetch: sinon.stub().resolves({ + models: [] + }) + }; + } + }; + Member.findOne.resolves(member); + + const activeSubscriptionPrice = { + stripe_price_id: 'price_active_subscription', + nickname: 'Active Subscription', + currency: 'usd', + amount: 0 + }; + const complimentaryPrice = { + stripe_price_id: 'price_complimentary', + nickname: 'Complimentary', + currency: 'usd', + amount: 0 + }; + + productRepository = { + getDefaultProduct: sinon.stub().resolves({ + toJSON: () => { + return { + id: 'product_id_123', + name: 'Default tier', + description: null, + stripePrices: [activeSubscriptionPrice] + }; + } + }), + update: sinon.stub().resolves({ + toJSON: () => { + return { + stripePrices: [ + activeSubscriptionPrice, + complimentaryPrice + ] + }; + } + }) + }; + + const stripeAPIService = { + configured: true, + createCustomer: sinon.stub().resolves({ + id: 'cus_123', + email: 'member@example.com', + name: null + }), + createSubscription: sinon.stub().resolves({ + id: 'sub_123', + customer: 'cus_123' + }) + }; + + const StripeCustomer = { + upsert: sinon.stub().resolves() + }; + + const repo = new MemberRepository({ + Member, + StripeCustomer, + stripeAPIService, + productRepository, + OfferRedemption: mockOfferRedemption + }); + sinon.stub(repo, 'linkSubscription').resolves(); + + await repo.setComplimentarySubscription({ + id: 'member_id_123' + }, { + transacting: true + }); + + sinon.assert.calledOnce(productRepository.update); + sinon.assert.calledWith(stripeAPIService.createSubscription, 'cus_123', 'price_complimentary'); + sinon.assert.neverCalledWith(stripeAPIService.createSubscription, 'cus_123', 'price_active_subscription'); + }); }); describe('newsletter subscriptions', function () { diff --git a/ghost/core/test/unit/server/services/staff/staff-service.test.js b/ghost/core/test/unit/server/services/staff/staff-service.test.js index 471f64d9448..872f309b318 100644 --- a/ghost/core/test/unit/server/services/staff/staff-service.test.js +++ b/ghost/core/test/unit/server/services/staff/staff-service.test.js @@ -565,7 +565,7 @@ describe('StaffService', function () { testCommonPaidSubMailData({...stubs, member}); assert.equal(mailStub.calledWith( - sinon.match.has('html', 'Offer') + sinon.match.has('html', sinon.match('Offer')) ), false); }); @@ -580,7 +580,7 @@ describe('StaffService', function () { testCommonPaidSubMailData({...stubs, member: memberData}); assert.equal(mailStub.calledWith( - sinon.match.has('html', 'Offer') + sinon.match.has('html', sinon.match('Offer')) ), false); // check preview text @@ -656,6 +656,56 @@ describe('StaffService', function () { sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('Free week'))); sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('7 days free'))); }); + + it('sends paid subscription start alert with gift-derived trial period', async function () { + const trialStart = new Date('2022-08-01T07:30:39.882Z'); + const trialEnd = new Date('2022-08-31T07:30:39.882Z'); + const trialSubscription = { + ...subscription, + trialStart, + trialEnd + }; + + await service.emails.notifyPaidSubscriptionStarted({member, offer: null, tier, subscription: trialSubscription}, options); + + sinon.assert.calledOnce(mailStub); + testCommonPaidSubMailData({...stubs, member}); + + // Trial appears inline on the Tier line, not as a separate Offer block + sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('30 days free'))); + assert.equal(mailStub.calledWith( + sinon.match.has('html', sinon.match('Offer')) + ), false); + + // check preview text — trial appended to tier line + sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('Test Tier: $50.00/month - 30 days free'))); + }); + + it('does not show inline trial on tier line when a trial offer is present', async function () { + offer = { + name: 'Free week', + duration: 'trial', + type: 'trial', + amount: 7 + }; + const trialSubscription = { + ...subscription, + trialStart: new Date('2022-08-01T07:30:39.882Z'), + trialEnd: new Date('2022-08-08T07:30:39.882Z') + }; + + await service.emails.notifyPaidSubscriptionStarted({member, offer, tier, subscription: trialSubscription}, options); + + sinon.assert.calledOnce(mailStub); + testCommonPaidSubMailData({...stubs, member}); + + // The Offer block renders "7 days free" but the Tier line should NOT also show it + sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('Free week'))); + sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('7 days free'))); + assert.equal(mailStub.calledWith( + sinon.match.has('html', sinon.match('Test Tier: $50.00/month - 7 days free')) + ), false); + }); }); describe('notifyPaidSubscriptionCancel', function () { @@ -704,7 +754,7 @@ describe('StaffService', function () { sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('5 Sep 2024'))); assert.equal(mailStub.calledWith( - sinon.match.has('html', 'Offer') + sinon.match.has('html', sinon.match('Offer')) ), false); sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('Cancellation reason'))); @@ -750,7 +800,7 @@ describe('StaffService', function () { sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('5 Sep 2024'))); assert.equal(mailStub.calledWith( - sinon.match.has('html', 'Offer') + sinon.match.has('html', sinon.match('Offer')) ), false); sinon.assert.calledWith(mailStub, sinon.match.has('html', sinon.match('Cancellation reason'))); diff --git a/ghost/core/test/unit/server/web/api/middleware/cors.test.js b/ghost/core/test/unit/server/web/api/middleware/cors.test.js index b11ff7558a1..12ce1935aca 100644 --- a/ghost/core/test/unit/server/web/api/middleware/cors.test.js +++ b/ghost/core/test/unit/server/web/api/middleware/cors.test.js @@ -3,8 +3,8 @@ const sinon = require('sinon'); const rewire = require('rewire'); const configUtils = require('../../../../../utils/config-utils'); -let cors = rewire('../../../../../../core/server/web/api/middleware/cors')[1]; -let corsCaching = rewire('../../../../../../core/server/web/api/middleware/cors')[0]; +let cors = rewire('../../../../../../core/server/web/api/middleware/cors').corsMiddleware; +const {corsCaching} = rewire('../../../../../../core/server/web/api/middleware/cors'); describe('cors', function () { let res; @@ -37,7 +37,7 @@ describe('cors', function () { afterEach(async function () { sinon.restore(); await configUtils.restore(); - cors = rewire('../../../../../../core/server/web/api/middleware/cors')[1]; + cors = rewire('../../../../../../core/server/web/api/middleware/cors').corsMiddleware; }); it('should not be enabled without a request origin header', function (done) { diff --git a/ghost/core/test/unit/server/web/api/middleware/update-user-last-seen.test.js b/ghost/core/test/unit/server/web/api/middleware/update-user-last-seen.test.js index 076279b34d4..d5afcb0c240 100644 --- a/ghost/core/test/unit/server/web/api/middleware/update-user-last-seen.test.js +++ b/ghost/core/test/unit/server/web/api/middleware/update-user-last-seen.test.js @@ -1,18 +1,37 @@ -const assert = require('node:assert/strict'); -const {promisify} = require('node:util'); +const express = require('express'); const sinon = require('sinon'); +const request = require('supertest'); const moment = require('moment'); const updateUserLastSeenMiddleware = require('../../../../../../core/server/web/api/middleware/update-user-last-seen'); -const updateUserLastSeen = promisify(updateUserLastSeenMiddleware); - describe('updateUserLastSeenMiddleware', function () { afterEach(function () { sinon.restore(); }); + function createApp(user) { + const app = express(); + + app.use((req, res, next) => { + req.user = user; + next(); + }); + app.use(updateUserLastSeenMiddleware); + app.get('/', (_req, res) => { + res.sendStatus(204); + }); + app.use((err, _req, res, _next) => { + void _next; + res.status(500).json({message: err.message}); + }); + + return app; + } + it('calls next with no error if there is no user on the request', async function () { - await updateUserLastSeen({}, {}); + await request(createApp()) + .get('/') + .expect(204); }); it('calls next with no error if the current last_seen is less than an hour before now', async function () { @@ -20,7 +39,10 @@ describe('updateUserLastSeenMiddleware', function () { const fakeUser = { get: sinon.stub().withArgs('last_seen').returns(fakeLastSeen) }; - await updateUserLastSeen({user: fakeUser}, {}); + + await request(createApp(fakeUser)) + .get('/') + .expect(204); }); describe('when the last_seen is longer than an hour ago', function () { @@ -30,8 +52,10 @@ describe('updateUserLastSeenMiddleware', function () { get: sinon.stub().withArgs('last_seen').returns(fakeLastSeen), updateLastSeen: sinon.stub().resolves() }; - await updateUserLastSeen({user: fakeUser}, {}); + await request(createApp(fakeUser)) + .get('/') + .expect(204); sinon.assert.calledOnce(fakeUser.updateLastSeen); }); @@ -43,14 +67,10 @@ describe('updateUserLastSeenMiddleware', function () { updateLastSeen: sinon.stub().rejects(fakeError) }; - await assert.rejects( - updateUserLastSeen({user: fakeUser}, {}), - (err) => { - assert.equal(err, fakeError); - return true; - } - ); - + await request(createApp(fakeUser)) + .get('/') + .expect(500) + .expect({message: fakeError.message}); sinon.assert.calledOnce(fakeUser.updateLastSeen); }); }); diff --git a/ghost/core/test/utils/api.js b/ghost/core/test/utils/api.js index 7cf2906a623..d4d01739a3e 100644 --- a/ghost/core/test/utils/api.js +++ b/ghost/core/test/utils/api.js @@ -1,7 +1,6 @@ const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); const _ = require('lodash'); -const url = require('url'); const moment = require('moment'); const DataGenerator = require('./fixtures/data-generator'); const config = require('../../core/shared/config'); @@ -15,11 +14,11 @@ function getURL() { } function getSigninURL() { - return url.resolve(protocol + host + ':' + port, 'ghost/signin/'); + return new URL('ghost/signin/', protocol + host + ':' + port).toString(); } function getAdminURL() { - return url.resolve(protocol + host + ':' + port, 'ghost/'); + return new URL('ghost/', protocol + host + ':' + port).toString(); } function isISO8601(date) {