Skip to content

Commit a144a45

Browse files
committed
šŸ› Fixed misleading "check your email" popup for already-subscribed members
closes https://linear.app/ghost/issue/BER-3724/show-correct-error-for-active-subscriber-checkout-attempts When a logged-in paid member clicked a paid signup button, e.g. a theme button using a `data-portal="signup/{tier}/{cadence}"` attribute, Portal showed the magic-link confirmation page even though the server rejects the checkout without sending any email, so members were told to check their inbox for an email that never arrived. The magic-link page is only correct for the anonymous flow, where the server emails a sign-in link before rejecting the checkout. Logged-in members now see an error notification explaining they already have an active subscription, reusing the existing translated string from the gift redemption flow.
1 parent b9b2f65 commit a144a45

7 files changed

Lines changed: 94 additions & 8 deletions

File tree

ā€Žapps/portal/CONTEXT.mdā€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ A site control that starts checkout for a membership plan, sending visitors dire
2020

2121
An attempt to start checkout for a membership plan. Checkout attempts are protected by request limits across checkout traffic and repeated attempts against one email address.
2222

23-
When a checkout attempt indicates that the submitted email should sign in instead, Portal continues with the sign-in email flow rather than asking the visitor to retry checkout.
23+
When a checkout attempt is rejected because an active paid subscription already exists: a signed-out visitor is continued into the sign-in email flow, while a signed-in member is told they already have an active subscription — no sign-in email is sent.

ā€Žapps/portal/package.jsonā€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tryghost/portal",
3-
"version": "2.69.2",
3+
"version": "2.69.3",
44
"license": "MIT",
55
"repository": "https://github.com/TryGhost/Ghost",
66
"author": "Ghost Foundation",

ā€Žapps/portal/src/actions.jsā€Ž

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ async function signup({data, state, api}) {
216216
};
217217
} catch (e) {
218218
if (e.code === CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION) {
219+
if (state.member) {
220+
return {
221+
action: 'signup:failed',
222+
popupNotification: createPopupNotification({
223+
type: 'signup:failed', autoHide: false, closeable: true, state, status: 'error',
224+
message: t('You already have an active subscription.')
225+
})
226+
};
227+
}
228+
219229
return {
220230
page: 'magiclink',
221231
lastPage: 'signin',

ā€Žapps/portal/src/app.jsā€Ž

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,9 @@ export default class App extends React.Component {
285285
locale: i18nLanguage
286286
};
287287

288-
this.handleSignupQuery({site, pageQuery, member});
289-
290-
this.setState(state);
288+
this.setState(state, () => {
289+
this.handleSignupQuery({site, pageQuery, member});
290+
});
291291

292292
// Listen to preview mode changes
293293
this.hashHandler = () => {

ā€Žapps/portal/test/actions.test.tsā€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,48 @@ describe('signup action', () => {
111111
});
112112
expect(result).not.toHaveProperty('action', 'signup:failed');
113113
});
114+
115+
test('shows an error when a logged-in member checkout finds an existing subscription', async () => {
116+
const checkoutError = new Error('A subscription exists for this Member.') as Error & {code: string};
117+
checkoutError.code = 'CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION';
118+
119+
const mockApi = {
120+
member: {
121+
checkoutPlan: vi.fn(() => Promise.reject(checkoutError))
122+
}
123+
};
124+
const state = {
125+
site: {},
126+
member: {
127+
name: 'Jamie Larson',
128+
email: 'jamie@example.com',
129+
paid: true
130+
}
131+
};
132+
133+
const result = await ActionHandler({
134+
action: 'signup',
135+
data: {
136+
plan: 'price_123',
137+
tierId: 'tier_123',
138+
cadence: 'month'
139+
},
140+
state,
141+
api: mockApi
142+
});
143+
144+
// No sign-in email is sent for authenticated members, so the
145+
// check-your-email page would be misleading.
146+
expect(result).not.toHaveProperty('page', 'magiclink');
147+
expect(result).toMatchObject({
148+
action: 'signup:failed',
149+
popupNotification: {
150+
type: 'signup:failed',
151+
status: 'error',
152+
message: 'You already have an active subscription.'
153+
}
154+
});
155+
});
114156
});
115157

116158
describe('redeemGift action', () => {

ā€Žapps/portal/test/signup-flow.test.jsā€Ž

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import App from '../src/app.js';
22
import {fireEvent, appRender, within, waitFor} from './utils/test-utils';
3-
import {offer as FixtureOffer, site as FixtureSite} from './utils/test-fixtures';
3+
import {offer as FixtureOffer, site as FixtureSite, member as FixtureMember} from './utils/test-fixtures';
44
import setupGhostApi from '../src/utils/api.js';
55

66
// Simple deep clone function
@@ -85,7 +85,7 @@ const offerSetup = async ({site, member = null, offer}) => {
8585
};
8686
};
8787

88-
const setup = async ({site, member = null}) => {
88+
const setup = async ({site, member = null, checkoutPlanError = null}) => {
8989
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
9090
ghostApi.init = vi.fn(() => {
9191
return Promise.resolve({
@@ -113,6 +113,9 @@ const setup = async ({site, member = null}) => {
113113
});
114114

115115
ghostApi.member.checkoutPlan = vi.fn(() => {
116+
if (checkoutPlanError) {
117+
return Promise.reject(checkoutPlanError);
118+
}
116119
return Promise.resolve();
117120
});
118121

@@ -579,6 +582,37 @@ describe('Signup', () => {
579582
});
580583
});
581584

585+
test('via direct signup link shows an error instead of the magic link page', async () => {
586+
const siteData = FixtureSite.singleTier.basic;
587+
const paidTier = siteData.products.find(p => p.type === 'paid');
588+
window.location.hash = `#/portal/signup/${paidTier.id}/monthly`;
589+
590+
try {
591+
const checkoutError = Object.assign(
592+
new Error('A subscription exists for this Member.'),
593+
{code: 'CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION'}
594+
);
595+
596+
const {ghostApi, ...utils} = await setup({
597+
site: siteData,
598+
member: FixtureMember.paid,
599+
checkoutPlanError: checkoutError
600+
});
601+
602+
const popupFrame = await utils.findByTitle(/portal-popup/i);
603+
604+
await waitFor(() => {
605+
expect(ghostApi.member.checkoutPlan).toHaveBeenCalled();
606+
});
607+
await waitFor(() => {
608+
expect(within(popupFrame.contentDocument).queryByText(/You already have an active subscription\./i)).toBeInTheDocument();
609+
});
610+
expect(within(popupFrame.contentDocument).queryByText(/Now check your email!/i)).not.toBeInTheDocument();
611+
} finally {
612+
window.location.hash = '';
613+
}
614+
});
615+
582616
test('to an offer via link', async () => {
583617
window.location.hash = `#/portal/offers/${FixtureOffer.id}`;
584618
const {

ā€Žghost/i18n/locales/context.jsonā€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@
399399
"X (Twitter)": "Share button in Portal share modal",
400400
"Yearly": "A label indicating an annual payment cadence",
401401
"Yesterday": "Time a comment was placed",
402-
"You already have an active subscription.": "Notification shown to a member who tries to redeem a gift subscription while they already have an active paid subscription",
402+
"You already have an active subscription.": "Notification shown to a member who tries to redeem a gift subscription or start a paid checkout while they already have an active paid subscription",
403403
"You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {site}.": "Shown at the bottom of the newsletter. Status will be one of free/paid/trialing/complimentary - see those strings below.",
404404
"You can also copy & paste this URL into your browser:": "Descriptive text displayed underneath the buttons in login/signup emails, right before authentication URLs which can be copy/pasted",
405405
"You can unsubscribe from these notifications at {profileUrl}.": "Footer text in comments email to manage notification preferences for users",

0 commit comments

Comments
Ā (0)