Skip to content

Commit 15fb105

Browse files
committed
🐛 Fixed paid checkout returning to homepage instead of originating page
ref https://linear.app/ghost/issue/PLA-86 ref #28369 - checkoutPlan() already derived a contextual cancelUrl from window.location but had no equivalent fallback for successUrl, so the standard signup/upgrade callers omitted it and the backend fell back to the site-root default — landing paid members on the homepage after Stripe Checkout instead of the article they just unlocked - mirrors the existing cancelUrl logic and continueGiftCheckout(), which already does this for gift flows - backend sanitizeReturnUrl() already enforces same-origin, so the contextual URL flows through safely with no server change needed
1 parent 0aeb5a5 commit 15fb105

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

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)