Skip to content

Commit 3f953ef

Browse files
authored
🐛 Fixed paid checkout returning to homepage instead of originating page (#28485)
ref https://linear.app/ghost/issue/PLA-86 fixes #28369 When a member starts a paid signup or upgrade from a page other than the homepage, Portal returned them to the **site root** after a successful Stripe Checkout (e.g. `https://example.com/?stripe=success`) instead of the page where checkout began. Cancelling checkout already returned to the originating page, so the behaviour was inconsistent — and especially disruptive for paywall flows, where a member who just paid to unlock an article was bounced to the homepage instead of back to the article.
1 parent 9ac4569 commit 3f953ef

3 files changed

Lines changed: 94 additions & 1 deletion

File tree

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.4",
3+
"version": "2.69.5",
44
"license": "MIT",
55
"repository": "https://github.com/TryGhost/Ghost",
66
"author": "Ghost Foundation",

apps/portal/src/utils/api.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,11 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
493493
const identity = await api.member.identity();
494494
const url = endpointFor({type: 'members', resource: 'create-stripe-checkout-session'});
495495

496+
if (!successUrl) {
497+
const checkoutSuccessUrl = window.location.href.startsWith(siteUrlObj.href) ? new URL(window.location.href) : new URL(siteUrl);
498+
checkoutSuccessUrl.searchParams.set('stripe', 'success');
499+
successUrl = checkoutSuccessUrl.href;
500+
}
496501
if (!cancelUrl) {
497502
const checkoutCancelUrl = window.location.href.startsWith(siteUrlObj.href) ? new URL(window.location.href) : new URL(siteUrl);
498503
checkoutCancelUrl.searchParams.set('stripe', 'cancel');

apps/portal/test/api.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,3 +227,91 @@ describe('Portal API member checkout', () => {
227227
});
228228
});
229229
});
230+
231+
describe('Portal API plan checkout', () => {
232+
let originalLocation;
233+
234+
const mockCheckoutFetch = () => {
235+
vi.spyOn(window, 'fetch').mockImplementation((url) => {
236+
if (url.includes('/members/api/session/')) {
237+
return Promise.resolve(new Response('identity-token', {status: 200}));
238+
}
239+
240+
return Promise.resolve(new Response(JSON.stringify({
241+
url: 'https://checkout.stripe.com/plan-session'
242+
}), {
243+
status: 200,
244+
headers: {
245+
'Content-Type': 'application/json'
246+
}
247+
}));
248+
});
249+
};
250+
251+
const lastCheckoutBody = () => {
252+
const call = window.fetch.mock.calls.find(([url]) => url.includes('/members/api/create-stripe-checkout-session/'));
253+
return JSON.parse(call[1].body);
254+
};
255+
256+
beforeEach(() => {
257+
vi.restoreAllMocks();
258+
originalLocation = window.location;
259+
delete window.location;
260+
window.location = {
261+
href: 'https://example.com/paid-article/',
262+
assign: vi.fn()
263+
};
264+
});
265+
266+
afterEach(() => {
267+
window.location = originalLocation;
268+
});
269+
270+
test('derives a contextual successUrl from the current page when none is supplied', async () => {
271+
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
272+
mockCheckoutFetch();
273+
274+
await ghostApi.member.checkoutPlan({
275+
plan: 'price_123',
276+
tierId: 'tier_123',
277+
cadence: 'month'
278+
});
279+
280+
const body = lastCheckoutBody();
281+
expect(body.successUrl).toBe('https://example.com/paid-article/?stripe=success');
282+
expect(body.cancelUrl).toBe('https://example.com/paid-article/?stripe=cancel');
283+
});
284+
285+
test('falls back to the site root when the current page is off-site', async () => {
286+
window.location.href = 'https://evil.example.org/phishing/';
287+
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
288+
mockCheckoutFetch();
289+
290+
await ghostApi.member.checkoutPlan({
291+
plan: 'price_123',
292+
tierId: 'tier_123',
293+
cadence: 'month'
294+
});
295+
296+
const body = lastCheckoutBody();
297+
expect(body.successUrl).toBe('https://example.com/?stripe=success');
298+
expect(body.cancelUrl).toBe('https://example.com/?stripe=cancel');
299+
});
300+
301+
test('preserves an explicitly supplied successUrl', async () => {
302+
const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
303+
mockCheckoutFetch();
304+
305+
await ghostApi.member.checkoutPlan({
306+
plan: 'price_123',
307+
tierId: 'tier_123',
308+
cadence: 'month',
309+
successUrl: 'https://example.com/custom-welcome/',
310+
cancelUrl: 'https://example.com/custom-cancel/'
311+
});
312+
313+
const body = lastCheckoutBody();
314+
expect(body.successUrl).toBe('https://example.com/custom-welcome/');
315+
expect(body.cancelUrl).toBe('https://example.com/custom-cancel/');
316+
});
317+
});

0 commit comments

Comments
 (0)