Skip to content

Commit 55a87a6

Browse files
committed
Improve sub cancellation UX & support PayPro via new cancel API
1 parent 6ab3754 commit 55a87a6

File tree

3 files changed

+140
-39
lines changed

3 files changed

+140
-39
lines changed

src/components/settings/settings-page.tsx

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import * as _ from 'lodash';
22
import * as React from 'react';
33
import { observer, inject } from "mobx-react";
4-
import { action, computed } from 'mobx';
54
import * as dedent from 'dedent';
65
import {
7-
distanceInWordsStrict, format
6+
distanceInWordsStrict, distanceInWordsToNow, format
87
} from 'date-fns';
9-
import { get } from 'typesafe-get';
108

119
import { WithInjected } from '../../types';
1210
import { styled, Theme, ThemeName } from '../../styles';
13-
import { WarningIcon } from '../../icons';
11+
import { Icon, WarningIcon } from '../../icons';
1412

1513
import { AccountStore } from '../../model/account/account-store';
14+
import { SubscriptionPlans } from '../../model/account/subscriptions';
1615
import { UiStore } from '../../model/ui-store';
1716
import { serverVersion, versionSatisfies, PORT_RANGE_SERVER_RANGE } from '../../services/service-versions';
1817

@@ -106,6 +105,13 @@ const EditorContainer = styled.div`
106105
flex-grow: 1;
107106
`;
108107

108+
const AccountUpdateSpinner = styled(Icon).attrs(() => ({
109+
icon: ['fas', 'spinner'],
110+
spin: true
111+
}))`
112+
margin: 0 0 0 10px;
113+
`;
114+
109115
@inject('accountStore')
110116
@inject('uiStore')
111117
@observer
@@ -119,7 +125,9 @@ class SettingsPage extends React.Component<SettingsPageProps> {
119125
userEmail,
120126
userSubscription,
121127
subscriptionPlans,
128+
isAccountUpdateInProcess,
122129
getPro,
130+
canManageSubscription,
123131
logOut
124132
} = this.props.accountStore;
125133

@@ -172,13 +180,16 @@ class SettingsPage extends React.Component<SettingsPageProps> {
172180
'deleted': 'Cancelled'
173181
}[sub.status]) || 'Unknown'
174182
}
183+
{ isAccountUpdateInProcess &&
184+
<AccountUpdateSpinner />
185+
}
175186
</ContentValue>
176187

177188
<ContentLabel>
178189
Subscription plan
179190
</ContentLabel>
180191
<ContentValue>
181-
{ get(subscriptionPlans, sub.plan, 'name') || 'Unknown' }
192+
{ subscriptionPlans[sub.plan]?.name ?? 'Unknown' }
182193
</ContentValue>
183194

184195
<ContentLabel>
@@ -212,27 +223,23 @@ class SettingsPage extends React.Component<SettingsPageProps> {
212223
View latest invoice
213224
</SettingsButtonLink>
214225
}
215-
{ sub.status !== 'deleted' &&
216-
sub.updateBillingDetailsUrl &&
217-
<SettingsButtonLink
218-
href={ sub.updateBillingDetailsUrl }
219-
target='_blank'
220-
rel='noreferrer noopener'
221-
highlight={sub.status === 'past_due'}
222-
>
223-
Update billing details
224-
</SettingsButtonLink>
225-
}
226-
{ sub.status !== 'deleted' &&
227-
sub.cancelSubscriptionUrl &&
228-
<SettingsButtonLink
229-
href={ sub.cancelSubscriptionUrl }
230-
target='_blank'
231-
rel='noreferrer noopener'
226+
{ canManageSubscription && <>
227+
{ sub.updateBillingDetailsUrl &&
228+
<SettingsButtonLink
229+
href={sub.updateBillingDetailsUrl}
230+
target='_blank'
231+
rel='noreferrer noopener'
232+
highlight={sub.status === 'past_due'}
233+
>
234+
Update billing details
235+
</SettingsButtonLink>
236+
}
237+
<SettingsButton
238+
onClick={this.confirmSubscriptionCancellation}
232239
>
233240
Cancel subscription
234-
</SettingsButtonLink>
235-
}
241+
</SettingsButton>
242+
</> }
236243
<SettingsButton onClick={logOut}>Log out</SettingsButton>
237244
</AccountControls>
238245

@@ -325,6 +332,40 @@ class SettingsPage extends React.Component<SettingsPageProps> {
325332
</SettingPageContainer>
326333
</SettingsPageScrollContainer>;
327334
}
335+
336+
confirmSubscriptionCancellation = () => {
337+
const subscription = this.props.accountStore.userSubscription;
338+
if (!subscription) {
339+
throw new Error("Can't cancel without a subscription");
340+
}
341+
342+
const planName = SubscriptionPlans[subscription.plan].name;
343+
344+
let cancelEffect: string;
345+
346+
if (subscription.status === 'active') {
347+
cancelEffect = `It will remain usable until it expires in ${
348+
distanceInWordsToNow(subscription.expiry)
349+
} but will not renew.`;
350+
} else if (subscription.status === 'past_due') {
351+
cancelEffect = 'No more renewals will be attempted and it will deactivate immediately.';
352+
} else {
353+
throw new Error(`Cannot cancel subscription with status ${subscription.status}`);
354+
}
355+
356+
const confirmed = confirm([
357+
`This will cancel your HTTP Toolkit ${planName} subscription.`,
358+
cancelEffect,
359+
"Are you sure?"
360+
].join('\n\n'));
361+
362+
if (!confirmed) return;
363+
364+
this.props.accountStore.cancelSubscription().catch((e) => {
365+
alert(e.message);
366+
reportError(e);
367+
});
368+
};
328369
}
329370

330371
// Annoying cast required to handle the store prop nicely in our types

src/model/account/account-store.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
User,
1414
getLatestUserData,
1515
getLastUserData,
16-
RefreshRejectedError
16+
RefreshRejectedError,
17+
cancelSubscription
1718
} from './auth';
1819
import {
1920
SubscriptionPlans,
@@ -65,6 +66,10 @@ export class AccountStore {
6566
@observable
6667
accountDataLastUpdated = 0;
6768

69+
// Set when we know a checkout/cancel is processing elsewhere:
70+
@observable
71+
isAccountUpdateInProcess = false;
72+
6873
@computed get userEmail() {
6974
return this.user.email;
7075
}
@@ -272,8 +277,24 @@ export class AccountStore {
272277

273278
private purchasePlan = flow(function * (this: AccountStore, email: string, sku: SKU) {
274279
openCheckout(email, sku);
280+
275281
this.modal = 'post-checkout';
282+
this.isAccountUpdateInProcess = true;
283+
yield this.waitForUserUpdate(() => this.isPaidUser || !this.modal);
284+
this.isAccountUpdateInProcess = false;
285+
this.modal = undefined;
286+
287+
trackEvent({
288+
category: 'Account',
289+
action: this.isPaidUser ? 'Checkout complete' : 'Checkout cancelled',
290+
value: sku
291+
});
292+
});
276293

294+
private waitForUserUpdate = flow(function * (
295+
this: AccountStore,
296+
completedCheck: () => boolean
297+
) {
277298
let focused = true;
278299

279300
const setFocused = () => {
@@ -288,10 +309,11 @@ export class AccountStore {
288309
window.addEventListener('focus', setFocused);
289310
window.addEventListener('blur', setUnfocused);
290311

291-
// Keep checking the user's subscription data whilst they check out in their browser...
312+
// Keep checking the user's subscription data at intervals, whilst other processes
313+
// (browser checkout, update from payment provider) complete elsewhere...
292314
yield this.updateUser();
293315
let ticksSinceCheck = 0;
294-
while (!this.isPaidUser && this.modal) {
316+
while (!completedCheck()) {
295317
yield delay(1000);
296318
ticksSinceCheck += 1;
297319

@@ -302,23 +324,33 @@ export class AccountStore {
302324
}
303325
}
304326

305-
if (this.isPaidUser && !focused) window.focus(); // Jump back to the front after checkout
327+
if (completedCheck() && !focused) window.focus(); // Jump back to the front after update
306328

307329
window.removeEventListener('focus', setFocused);
308330
window.removeEventListener('blur', setUnfocused);
309331

310-
trackEvent({
311-
category: 'Account',
312-
action: this.isPaidUser ? 'Checkout complete' : 'Checkout cancelled',
313-
value: sku
314-
});
315-
316-
this.modal = undefined;
317332
});
318333

319334
@action.bound
320335
cancelCheckout() {
321336
this.modal = this.selectedPlan = undefined;
322337
}
323338

339+
get canManageSubscription() {
340+
return !!this.userSubscription?.canManageSubscription;
341+
}
342+
343+
cancelSubscription = flow(function * (this: AccountStore) {
344+
yield cancelSubscription();
345+
346+
console.log('Subscription cancel requested');
347+
this.isAccountUpdateInProcess = true;
348+
yield this.waitForUserUpdate(() =>
349+
!this.user.subscription ||
350+
this.user.subscription.status === 'deleted'
351+
);
352+
this.isAccountUpdateInProcess = false;
353+
console.log('Subscription cancellation confirmed');
354+
});
355+
324356
}

src/model/account/auth.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ type AppData = {
222222
update_url?: string;
223223
cancel_url?: string;
224224
last_receipt_url?: string;
225+
can_manage_subscription?: boolean;
225226
feature_flags?: string[];
226227
banned?: boolean;
227228
}
@@ -233,6 +234,7 @@ type SubscriptionData = {
233234
updateBillingDetailsUrl?: string;
234235
cancelSubscriptionUrl?: string;
235236
lastReceiptUrl?: string;
237+
canManageSubscription: boolean;
236238
};
237239

238240
export type User = {
@@ -297,7 +299,8 @@ function parseUserData(userJwt: string | null): User {
297299
expiry: appData.subscription_expiry ? new Date(appData.subscription_expiry) : undefined,
298300
updateBillingDetailsUrl: appData.update_url,
299301
cancelSubscriptionUrl: appData.cancel_url,
300-
lastReceiptUrl: appData.last_receipt_url
302+
lastReceiptUrl: appData.last_receipt_url,
303+
canManageSubscription: !!appData.can_manage_subscription
301304
};
302305

303306
if (_.some(subscription) && !subscription.plan) {
@@ -312,10 +315,15 @@ function parseUserData(userJwt: string | null): User {
312315
'cancelSubscriptionUrl'
313316
];
314317

318+
const isCompleteSubscriptionData = _.every(
319+
_.omit(subscription, ...optionalFields),
320+
v => !_.isNil(v) // Not just truthy: canManageSubscription can be false on valid sub
321+
);
322+
315323
return {
316324
email: appData.email,
317325
// Use undefined rather than {} when there's any missing required sub fields
318-
subscription: _.every(_.omit(subscription, ...optionalFields))
326+
subscription: isCompleteSubscriptionData
319327
? subscription as SubscriptionData
320328
: undefined,
321329
featureFlags: appData.feature_flags || [],
@@ -327,12 +335,32 @@ async function requestUserData(): Promise<string> {
327335
const token = await getToken();
328336
if (!token) return '';
329337

330-
const appDataResponse = await fetch(`${ACCOUNTS_API}/get-app-data`, {
338+
const response = await fetch(`${ACCOUNTS_API}/get-app-data`, {
331339
method: 'GET',
332340
headers: {
333341
'Authorization': `Bearer ${token}`
334342
}
335343
});
336344

337-
return appDataResponse.text();
345+
if (!response.ok) {
346+
throw new Error(`Unexpected ${response.status} response for app data`);
347+
}
348+
349+
return response.text();
350+
}
351+
352+
export async function cancelSubscription() {
353+
const token = await getToken();
354+
if (!token) throw new Error("Can't cancel account without an auth token");
355+
356+
const response = await fetch(`${ACCOUNTS_API}/cancel-subscription`, {
357+
method: 'POST',
358+
headers: {
359+
'Authorization': `Bearer ${token}`
360+
}
361+
});
362+
363+
if (!response.ok) {
364+
throw new Error(`Unexpected ${response.status} response cancelling subscription`);
365+
}
338366
}

0 commit comments

Comments
 (0)