From a4107b8202fa4764a37404f8fa60e4cd325ba6aa Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Tue, 2 Jul 2024 20:58:20 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20incorrect=20member=20sub?= =?UTF-8?q?scription=20details=20in=20Admin=20(#20476)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fixes https://linear.app/tryghost/issue/ENG-642 - When a subscription is in the `canceled` state the corresponding Member has no access to the Ghost site. The only time a Member will continue to have access if their subscription is due to cancel at the period end is if it is still in an active state, which is one of `active` `trialing` `unpaid` or `past_due` - When a subscription is canceled immediately (i.e. before the end of the current billing period), we now render "Ended" without a date, because we don't store the cancellation date in the subscription object. We previously used "Ended {current_period_end}" which would sometimes lead to dates in the future - Bonus: refactored code and added unit tests --------- Co-authored-by: Sag --- .../components/gh-member-settings-form.hbs | 16 +- .../app/components/gh-member-settings-form.js | 39 +- ghost/admin/app/utils/subscription-data.js | 98 +++++ .../unit/utils/subscription-data-test.js | 341 ++++++++++++++++++ 4 files changed, 443 insertions(+), 51 deletions(-) create mode 100644 ghost/admin/app/utils/subscription-data.js create mode 100644 ghost/admin/tests/unit/utils/subscription-data-test.js diff --git a/ghost/admin/app/components/gh-member-settings-form.hbs b/ghost/admin/app/components/gh-member-settings-form.hbs index f1a7630274..d301aadf30 100644 --- a/ghost/admin/app/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/components/gh-member-settings-form.hbs @@ -147,21 +147,7 @@ {{/if}} {{/if}} - {{#if sub.isComplimentary}} - {{#if sub.compExpiry}} - Expires {{sub.compExpiry}} - {{/if}} - {{else}} - {{#if sub.hasEnded}} - Ended {{sub.validUntil}} - {{else if sub.willEndSoon}} - Has access until {{sub.validUntil}} - {{else if sub.trialUntil}} - Ends {{sub.trialUntil}} - {{else}} - Renews {{sub.validUntil}} - {{/if}} - {{/if}} + {{sub.validityDetails}} diff --git a/ghost/admin/app/components/gh-member-settings-form.js b/ghost/admin/app/components/gh-member-settings-form.js index d7f4162dec..bcd9193bf5 100644 --- a/ghost/admin/app/components/gh-member-settings-form.js +++ b/ghost/admin/app/components/gh-member-settings-form.js @@ -1,8 +1,7 @@ import Component from '@glimmer/component'; -import moment from 'moment-timezone'; import {action} from '@ember/object'; import {didCancel, task} from 'ember-concurrency'; -import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency'; +import {getSubscriptionData} from 'ghost-admin/utils/subscription-data'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; @@ -60,41 +59,9 @@ export default class extends Component { return typeof value.id !== 'undefined' && self.findIndex(element => (element.tier_id || element.id) === (value.tier_id || value.id)) === index; }); - let subscriptionData = subscriptions.filter((sub) => { - return !!sub.price; - }).map((sub) => { - const periodEnded = sub.current_period_end && new Date(sub.current_period_end) < new Date(); - const data = { - ...sub, - attribution: { - ...sub.attribution, - referrerSource: sub.attribution?.referrer_source || 'Unknown', - referrerMedium: sub.attribution?.referrer_medium || '-' - }, - startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-', - validUntil: sub.current_period_end ? moment(sub.current_period_end).format('D MMM YYYY') : '-', - hasEnded: sub.status === 'canceled' && periodEnded, - willEndSoon: sub.cancel_at_period_end || (sub.status === 'canceled' && !periodEnded), - cancellationReason: sub.cancellation_reason, - price: { - ...sub.price, - currencySymbol: getSymbol(sub.price.currency), - nonDecimalAmount: getNonDecimal(sub.price.amount) - }, - isComplimentary: !sub.id - }; - if (sub.trial_end_at) { - const inTrialMode = moment(sub.trial_end_at).isAfter(new Date(), 'day'); - if (inTrialMode) { - data.trialUntil = moment(sub.trial_end_at).format('D MMM YYYY'); - } - } + let subsWithPrice = subscriptions.filter(sub => !!sub.price); + let subscriptionData = subsWithPrice.map(sub => getSubscriptionData(sub)); - if (!sub.id && sub.tier?.expiry_at) { - data.compExpiry = moment(sub.tier.expiry_at).utc().format('D MMM YYYY'); - } - return data; - }); return tiers.map((tier) => { let tierSubscriptions = subscriptionData.filter((subscription) => { return subscription?.price?.tier?.tier_id === (tier.tier_id || tier.id); diff --git a/ghost/admin/app/utils/subscription-data.js b/ghost/admin/app/utils/subscription-data.js new file mode 100644 index 0000000000..347265ace4 --- /dev/null +++ b/ghost/admin/app/utils/subscription-data.js @@ -0,0 +1,98 @@ +import moment from 'moment-timezone'; +import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency'; + +export function getSubscriptionData(sub) { + const data = { + ...sub, + attribution: { + ...sub.attribution, + referrerSource: sub.attribution?.referrer_source || 'Unknown', + referrerMedium: sub.attribution?.referrer_medium || '-' + }, + startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-', + validUntil: validUntil(sub), + hasEnded: isCanceled(sub), + willEndSoon: isSetToCancel(sub), + cancellationReason: sub.cancellation_reason, + price: { + ...sub.price, + currencySymbol: getSymbol(sub.price.currency), + nonDecimalAmount: getNonDecimal(sub.price.amount) + }, + isComplimentary: isComplimentary(sub), + compExpiry: compExpiry(sub), + trialUntil: trialUntil(sub) + }; + + data.validityDetails = validityDetails(data); + + return data; +} + +export function validUntil(sub) { + // If a subscription has been canceled immediately, don't render the end of validity date + // Reason: we don't store the exact cancelation date in the subscription object + if (sub.status === 'canceled' && !sub.cancel_at_period_end) { + return ''; + } + + // Otherwise, show the current period end date + if (sub.current_period_end) { + return moment(sub.current_period_end).format('D MMM YYYY'); + } + + return ''; +} + +export function isActive(sub) { + return ['active', 'trialing', 'past_due', 'unpaid'].includes(sub.status); +} + +export function isComplimentary(sub) { + return !sub.id; +} + +export function isCanceled(sub) { + return sub.status === 'canceled'; +} + +export function isSetToCancel(sub) { + return sub.cancel_at_period_end && isActive(sub); +} + +export function compExpiry(sub) { + if (!sub.id && sub.tier && sub.tier.expiry_at) { + return moment(sub.tier.expiry_at).utc().format('D MMM YYYY'); + } + + return undefined; +} + +export function trialUntil(sub) { + const inTrialMode = sub.trial_end_at && moment(sub.trial_end_at).isAfter(new Date(), 'day'); + if (inTrialMode) { + return moment(sub.trial_end_at).format('D MMM YYYY'); + } + + return undefined; +} + +export function validityDetails(data) { + if (data.isComplimentary && data.compExpiry) { + return `Expires ${data.compExpiry}`; + } + + if (data.hasEnded) { + return `Ended ${data.validUntil}`; + } + + if (data.willEndSoon) { + return `Has access until ${data.validUntil}`; + } + + if (data.trialUntil) { + return `Ends ${data.trialUntil}`; + } + + return `Renews ${data.validUntil}`; +} diff --git a/ghost/admin/tests/unit/utils/subscription-data-test.js b/ghost/admin/tests/unit/utils/subscription-data-test.js new file mode 100644 index 0000000000..be7a64bb78 --- /dev/null +++ b/ghost/admin/tests/unit/utils/subscription-data-test.js @@ -0,0 +1,341 @@ +import moment from 'moment-timezone'; +import {compExpiry, getSubscriptionData, isActive, isCanceled, isComplimentary, isSetToCancel, trialUntil, validUntil, validityDetails} from 'ghost-admin/utils/subscription-data'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; + +describe.only('Unit: Util: subscription-data', function () { + describe('validUntil', function () { + it('returns the end of the current billing period when the subscription is canceled at the end of the period', function () { + let sub = { + status: 'canceled', + cancel_at_period_end: true, + current_period_end: '2021-05-31' + }; + expect(validUntil(sub)).to.equal('31 May 2021'); + }); + + it('returns an empty string when the subscription is canceled immediately', function () { + let sub = { + status: 'canceled', + cancel_at_period_end: false, + current_period_end: '2021-05-31' + }; + expect(validUntil(sub)).to.equal(''); + }); + + it('returns the end of the current billing period when the subscription is active', function () { + let sub = { + status: 'active', + cancel_at_period_end: false, + current_period_end: '2021-05-31' + }; + expect(validUntil(sub)).to.equal('31 May 2021'); + }); + + it('returns the end of the current billing period when the subscription is in trial', function () { + let sub = { + status: 'trialing', + cancel_at_period_end: false, + current_period_end: '2021-05-31' + }; + expect(validUntil(sub)).to.equal('31 May 2021'); + }); + + it('returns the end of the current billing period when the subscription is past_due', function () { + let sub = { + status: 'past_due', + cancel_at_period_end: false, + current_period_end: '2021-05-31' + }; + expect(validUntil(sub)).to.equal('31 May 2021'); + }); + + it('returns the end of the current billing period when the subscription is unpaid', function () { + let sub = { + status: 'unpaid', + cancel_at_period_end: false, + current_period_end: '2021-05-31' + }; + expect(validUntil(sub)).to.equal('31 May 2021'); + }); + + // Extra data safety check, mainly for imported subscriptions + it('returns an empty string if the subcription is canceled immediately and has no current_period_start', function () { + let sub = { + status: 'canceled', + cancel_at_period_end: false + }; + expect(validUntil(sub)).to.equal(''); + }); + + // Extra data safety check, mainly for imported subscriptions + it('returns an empty string if the subscription has no current_period_end', function () { + let sub = { + status: 'active', + cancel_at_period_end: false + }; + expect(validUntil(sub)).to.equal(''); + }); + }); + + describe('isActive', function () { + it('returns true for active subscriptions', function () { + let sub = {status: 'active'}; + expect(isActive(sub)).to.be.true; + }); + + it('returns true for trialing subscriptions', function () { + let sub = {status: 'trialing'}; + expect(isActive(sub)).to.be.true; + }); + + it('returns true for past_due subscriptions', function () { + let sub = {status: 'past_due'}; + expect(isActive(sub)).to.be.true; + }); + + it('returns true for unpaid subscriptions', function () { + let sub = {status: 'unpaid'}; + expect(isActive(sub)).to.be.true; + }); + + it('returns false for canceled subscriptions', function () { + let sub = {status: 'canceled'}; + expect(isActive(sub)).to.be.false; + }); + }); + + describe('isComplimentary', function () { + it('returns true for complimentary subscriptions', function () { + let sub = {id: null}; + expect(isComplimentary(sub)).to.be.true; + }); + + it('returns false for paid subscriptions', function () { + let sub = {id: 'sub_123'}; + expect(isComplimentary(sub)).to.be.false; + }); + }); + + describe('isCanceled', function () { + it('returns true for canceled subscriptions', function () { + let sub = {status: 'canceled'}; + expect(isCanceled(sub)).to.be.true; + }); + + it('returns false for active subscriptions', function () { + let sub = {status: 'active'}; + expect(isCanceled(sub)).to.be.false; + }); + }); + + describe('isSetToCancel', function () { + it('returns true for subscriptions set to cancel at the end of the period', function () { + let sub = {status: 'active', cancel_at_period_end: true}; + expect(isSetToCancel(sub)).to.be.true; + }); + + it('returns false for canceled subscriptions', function () { + let sub = {status: 'canceled', cancel_at_period_end: true}; + expect(isSetToCancel(sub)).to.be.false; + }); + }); + + describe('trialUntil', function () { + it('returns the trial end date for subscriptions in trial', function () { + let sub = {status: 'trialing', trial_end_at: '2222-05-31'}; + expect(trialUntil(sub)).to.equal('31 May 2222'); + }); + + it('returns undefined for subscriptions not in trial', function () { + let sub = {status: 'active'}; + expect(trialUntil(sub)).to.be.undefined; + }); + }); + + describe('compExpiry', function () { + it('returns the complimentary expiry date for complimentary subscriptions', function () { + let sub = {id: null, tier: {expiry_at: moment.utc('2021-05-31').toISOString()}}; + expect(compExpiry(sub)).to.equal('31 May 2021'); + }); + + it('returns undefined for paid subscriptions', function () { + let sub = {id: 'sub_123'}; + expect(compExpiry(sub)).to.be.undefined; + }); + }); + + describe('validityDetails', function () { + it('returns "Expires {compExpiry}" for expired complimentary subscriptions', function () { + let data = { + isComplimentary: true, + compExpiry: '31 May 2021' + }; + expect(validityDetails(data)).to.equal('Expires 31 May 2021'); + }); + + it('returns "Ended {validUntil}" for canceled subscriptions', function () { + let data = { + hasEnded: true, + validUntil: '31 May 2021' + }; + expect(validityDetails(data)).to.equal('Ended 31 May 2021'); + }); + + it('returns "Has access until {validUntil}" for set to cancel subscriptions', function () { + let data = { + willEndSoon: true, + validUntil: '31 May 2021' + }; + expect(validityDetails(data)).to.equal('Has access until 31 May 2021'); + }); + + it('returns "Ends {validUntil}" for trial subscriptions', function () { + let data = { + trialUntil: '31 May 2021' + }; + expect(validityDetails(data)).to.equal('Ends 31 May 2021'); + }); + + it('returns "Renews {validUntil}" for active subscriptions', function () { + let data = { + validUntil: '31 May 2021' + }; + expect(validityDetails(data)).to.equal('Renews 31 May 2021'); + }); + }); + + describe('getSubscriptionData', function () { + it('returns the correct data for an active subscription', function () { + let sub = { + id: 'defined', + status: 'active', + cancel_at_period_end: false, + current_period_end: '2021-05-31', + trial_end_at: null, + tier: null, + price: { + currency: 'usd', + amount: 5000 + } + }; + let data = getSubscriptionData(sub); + + expect(data).to.include({ + isComplimentary: false, + compExpiry: undefined, + hasEnded: false, + validUntil: '31 May 2021', + willEndSoon: false, + trialUntil: undefined, + validityDetails: 'Renews 31 May 2021' + }); + }); + + it('returns the correct data for a trial subscription', function () { + let sub = { + id: 'defined', + status: 'trialing', + cancel_at_period_end: false, + current_period_end: '2222-05-31', + trial_end_at: '2222-05-31', + tier: null, + price: { + currency: 'usd', + amount: 5000 + } + }; + let data = getSubscriptionData(sub); + + expect(data).to.include({ + isComplimentary: false, + compExpiry: undefined, + hasEnded: false, + validUntil: '31 May 2222', + willEndSoon: false, + trialUntil: '31 May 2222', + validityDetails: 'Ends 31 May 2222' + }); + }); + + it('returns the correct data for an immediately canceled subscription', function () { + let sub = { + id: 'defined', + status: 'canceled', + cancel_at_period_end: false, + current_period_end: '2021-05-31', + trial_end_at: null, + tier: null, + price: { + currency: 'usd', + amount: 5000 + } + }; + let data = getSubscriptionData(sub); + + expect(data).to.include({ + isComplimentary: false, + compExpiry: undefined, + hasEnded: true, + validUntil: '', + willEndSoon: false, + trialUntil: undefined, + validityDetails: 'Ended ' + }); + }); + + it('returns the correct data for a subscription set to cancel at the end of the period', function () { + let sub = { + id: 'defined', + status: 'active', + cancel_at_period_end: true, + current_period_end: '2021-05-31', + trial_end_at: null, + tier: null, + price: { + currency: 'usd', + amount: 5000 + } + }; + let data = getSubscriptionData(sub); + + expect(data).to.include({ + isComplimentary: false, + compExpiry: undefined, + hasEnded: false, + validUntil: '31 May 2021', + willEndSoon: true, + trialUntil: undefined, + validityDetails: 'Has access until 31 May 2021' + }); + }); + + it('returns the correct data for a complimentary subscription', function () { + let sub = { + id: null, + status: 'active', + cancel_at_period_end: false, + current_period_end: '2021-05-31', + trial_end_at: null, + tier: { + expiry_at: moment.utc('2021-05-31').toISOString() + }, + price: { + currency: 'usd', + amount: 0 + } + }; + let data = getSubscriptionData(sub); + + expect(data).to.include({ + isComplimentary: true, + compExpiry: '31 May 2021', + hasEnded: false, + validUntil: '31 May 2021', + willEndSoon: false, + trialUntil: undefined, + validityDetails: 'Expires 31 May 2021' + }); + }); + }); +});