From e4f60ee028996804e0efb78be5299eb7a5858c5e Mon Sep 17 00:00:00 2001 From: Sag Date: Wed, 24 Jan 2024 12:16:26 +0100 Subject: [PATCH] Fixed member subscription status when multiple subs (#19530) fixes PROD-325 - if a member has multiple subscriptions, show the status of the active subscription - if a member has multiple active subscriptins, show the status of the subscription with the latest current_period_end date --- .../components/gh-member-settings-form.hbs | 24 ++++--- .../members/filters/next-billing-date.js | 12 ++-- .../members/filters/plan-interval.js | 10 +-- .../filters/subscription-start-date.js | 12 ++-- .../members/filters/subscription-status.js | 10 +-- .../app/helpers/most-relevant-subscription.js | 39 ++++++++++ .../most-relevant-subscription-test.js | 72 +++++++++++++++++++ 7 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 ghost/admin/app/helpers/most-relevant-subscription.js create mode 100644 ghost/admin/tests/unit/helpers/most-relevant-subscription-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 4eac44c1a0..2b6ab15b56 100644 --- a/ghost/admin/app/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/components/gh-member-settings-form.hbs @@ -143,20 +143,24 @@ {{else}} {{#if (or (eq sub.price.nickname "Monthly") (eq sub.price.nickname "Yearly"))}} {{else}} - {{sub.price.nickname}} + {{sub.price.nickname}} {{/if}} {{/if}} - {{#if sub.hasEnded}} - Ended {{sub.validUntil}} - {{else if sub.willEndSoon}} - Has access until {{sub.validUntil}} - {{else if sub.compExpiry}} - Expires {{sub.compExpiry}} - {{else if sub.trialUntil}} - Ends {{sub.trialUntil}} + {{#if sub.isComplimentary}} + {{#if sub.compExpiry}} + Expires {{sub.compExpiry}} + {{/if}} {{else}} - Renews {{sub.validUntil}} + {{#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}} diff --git a/ghost/admin/app/components/members/filters/next-billing-date.js b/ghost/admin/app/components/members/filters/next-billing-date.js index 2d6aad1de9..621d839365 100644 --- a/ghost/admin/app/components/members/filters/next-billing-date.js +++ b/ghost/admin/app/components/members/filters/next-billing-date.js @@ -1,15 +1,15 @@ import {DATE_RELATION_OPTIONS} from './relation-options'; import {getDateColumnValue} from './columns/date-column'; -import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; +import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; export const NEXT_BILLING_DATE_FILTER = { - label: 'Next billing date', - name: 'subscriptions.current_period_end', - valueType: 'date', - columnLabel: 'Next billing date', + label: 'Next billing date', + name: 'subscriptions.current_period_end', + valueType: 'date', + columnLabel: 'Next billing date', relationOptions: DATE_RELATION_OPTIONS, getColumnValue: (member, filter) => { - const subscription = mostRecentlyUpdated(member.subscriptions); + const subscription = mostRelevantSubscription(member.subscriptions); return getDateColumnValue(subscription?.current_period_end, filter); } }; diff --git a/ghost/admin/app/components/members/filters/plan-interval.js b/ghost/admin/app/components/members/filters/plan-interval.js index 9ad8df9ec6..79c3cc1899 100644 --- a/ghost/admin/app/components/members/filters/plan-interval.js +++ b/ghost/admin/app/components/members/filters/plan-interval.js @@ -1,11 +1,11 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; -import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; +import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; export const PLAN_INTERVAL_FILTER = { - label: 'Billing period', - name: 'subscriptions.plan_interval', - columnLabel: 'Billing period', + label: 'Billing period', + name: 'subscriptions.plan_interval', + columnLabel: 'Billing period', relationOptions: MATCH_RELATION_OPTIONS, valueType: 'options', options: [ @@ -13,7 +13,7 @@ export const PLAN_INTERVAL_FILTER = { {label: 'Yearly', name: 'year'} ], getColumnValue: (member) => { - const subscription = mostRecentlyUpdated(member.subscriptions); + const subscription = mostRelevantSubscription(member.subscriptions); if (!subscription) { return null; } diff --git a/ghost/admin/app/components/members/filters/subscription-start-date.js b/ghost/admin/app/components/members/filters/subscription-start-date.js index 42740304e9..771c1e52ec 100644 --- a/ghost/admin/app/components/members/filters/subscription-start-date.js +++ b/ghost/admin/app/components/members/filters/subscription-start-date.js @@ -1,15 +1,15 @@ import {DATE_RELATION_OPTIONS} from './relation-options'; import {getDateColumnValue} from './columns/date-column'; -import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; +import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; export const SUBSCRIPTION_START_DATE_FILTER = { - label: 'Paid start date', - name: 'subscriptions.start_date', - valueType: 'date', - columnLabel: 'Paid start date', + label: 'Paid start date', + name: 'subscriptions.start_date', + valueType: 'date', + columnLabel: 'Paid start date', relationOptions: DATE_RELATION_OPTIONS, getColumnValue: (member, filter) => { - const subscription = mostRecentlyUpdated(member.subscriptions); + const subscription = mostRelevantSubscription(member.subscriptions); return getDateColumnValue(subscription?.start_date, filter); } }; diff --git a/ghost/admin/app/components/members/filters/subscription-status.js b/ghost/admin/app/components/members/filters/subscription-status.js index 750fffdf99..d626693206 100644 --- a/ghost/admin/app/components/members/filters/subscription-status.js +++ b/ghost/admin/app/components/members/filters/subscription-status.js @@ -1,11 +1,11 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; -import {mostRecentlyUpdated} from 'ghost-admin/helpers/most-recently-updated'; +import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; export const SUBSCRIPTION_STATUS_FILTER = { - label: 'Stripe subscription status', - name: 'subscriptions.status', - columnLabel: 'Subscription Status', + label: 'Stripe subscription status', + name: 'subscriptions.status', + columnLabel: 'Subscription Status', relationOptions: MATCH_RELATION_OPTIONS, valueType: 'options', options: [ @@ -18,7 +18,7 @@ export const SUBSCRIPTION_STATUS_FILTER = { {label: 'Incomplete - Expired', name: 'incomplete_expired'} ], getColumnValue: (member) => { - const subscription = mostRecentlyUpdated(member.subscriptions); + const subscription = mostRelevantSubscription(member.subscriptions); if (!subscription) { return null; } diff --git a/ghost/admin/app/helpers/most-relevant-subscription.js b/ghost/admin/app/helpers/most-relevant-subscription.js new file mode 100644 index 0000000000..654888cbb4 --- /dev/null +++ b/ghost/admin/app/helpers/most-relevant-subscription.js @@ -0,0 +1,39 @@ +import moment from 'moment-timezone'; +import {helper} from '@ember/component/helper'; + +export function mostRelevantSubscription(subs) { + // Ignore comped subscriptions (without id) + const items = [...(subs || []).filter(sub => !!sub.id)]; + + // Find active subscription if any, then sort by latest current_period_end if needed + items.sort((a, b) => { + const isActiveA = ['active', 'trialing', 'unpaid', 'past_due'].includes(a.status); + const isActiveB = ['active', 'trialing', 'unpaid', 'past_due'].includes(b.status); + + // Sort by status, active first + if (isActiveA && !isActiveB) { + return -1; + } else if (!isActiveA && isActiveB) { + return 1; + } + + // Sort by current_period_end, latest first + const endDateA = moment(a.current_period_end); + const endDateB = moment(b.current_period_end); + + if (!endDateA.isValid()) { + return 1; + } else if (!endDateB.isValid()) { + return -1; + } + + return endDateB.valueOf() - endDateA.valueOf(); + }); + + return items[0] || null; +} + +export default helper(function ([items = []]) { + return mostRelevantSubscription(items); +}); + diff --git a/ghost/admin/tests/unit/helpers/most-relevant-subscription-test.js b/ghost/admin/tests/unit/helpers/most-relevant-subscription-test.js new file mode 100644 index 0000000000..e38f11155c --- /dev/null +++ b/ghost/admin/tests/unit/helpers/most-relevant-subscription-test.js @@ -0,0 +1,72 @@ +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; + +describe('Unit: Helper: most-relevant-subscription', function () { + it('returns active subscriptions first', function () { + const active = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; + const canceled = {id: 'b', status: 'canceled', current_period_end: '2022-03-04 16:10'}; + + const subs = [active, canceled]; + + expect(mostRelevantSubscription(subs)).to.equal(active); + }); + + it('returns the subscription with the latest current_period_end', function () { + const older = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; + const latest = {id: 'b', status: 'active', current_period_end: '2022-03-04 16:20'}; + + const subs = [older, latest]; + + expect(mostRelevantSubscription(subs)).to.equal(latest); + }); + + it('ignores comped subscriptions', function () { + const normal = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; + const comped = {id: null, status: 'active', current_period_end: '2022-03-04 16:20'}; + + const subs = [normal, comped]; + + expect(mostRelevantSubscription(subs)).to.equal(normal); + }); + + it('handles null or invalid dates', function () { + const a = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; + const b = {id: 'b', status: 'active', current_period_end: '2022-03-04 16:20'}; + const c = {id: 'c', status: 'active', current_period_end: ''}; + const d = {id: 'd', status: 'active', current_period_end: null}; + const e = {id: 'e', status: 'active', current_period_end: 'string'}; + + const subs = [a, b, c, d, e]; + + expect(mostRelevantSubscription(subs)).to.equal(b); + }); + + it('handles a single-element array', function () { + const a = {id: 'a', current_period_end: '2022-02-22'}; + + expect(mostRelevantSubscription([a])).to.equal(a); + }); + + it('handles null', function () { + expect(mostRelevantSubscription(null)).to.equal(null); + }); + + it('handles empty array', function () { + expect(mostRelevantSubscription([])).to.equal(null); + }); + + it('does not modify original array', function () { + const a = {id: 'a', status: 'active', current_period_end: '2022-03-04 16:10'}; + const b = {id: 'b', status: 'canceled', current_period_end: '2022-03-04 16:10'}; + const c = {id: null, status: 'active', current_period_end: '2022-03-04 16:10'}; + + const subs = [a, b, c]; + + mostRelevantSubscription(subs); + + expect(subs[0]).to.equal(a); + expect(subs[1]).to.equal(b); + expect(subs[2]).to.equal(c); + }); +});