(core) Improving billing page user experience

Summary:
Improving billing page user experience.
- Updated labels for canceled plan
- Adding option to downgrade from team plan to free team plan
- Updating default name for teamFree plan when it is not available in Stripe
- Minor bug fixes

Test Plan: updated tests

Reviewers: cyprien

Reviewed By: cyprien

Subscribers: cyprien

Differential Revision: https://phab.getgrist.com/D3515
This commit is contained in:
Jarosław Sadziński 2022-07-08 21:09:22 +02:00
parent c70b427483
commit 257dafe423
4 changed files with 72 additions and 22 deletions

View File

@ -48,6 +48,9 @@ export interface BillingModel {
getCustomerPortalUrl(): string;
// Renews plan (either by opening customer portal or creating Stripe Checkout session)
renewPlan(): string;
// Downgrades team plan. Currently downgrades only from team to team free plan, and
// only when plan is cancelled.
downgradePlan(planName: string): Promise<void>;
}
export interface ISubscriptionModel extends IBillingSubscription {
@ -124,6 +127,10 @@ export class BillingModelImpl extends Disposable implements BillingModel {
return this._billingAPI.renewPlan();
}
public async downgradePlan(planName: string): Promise<void> {
await this._billingAPI.downgradePlan(planName);
}
public async cancelCurrentPlan() {
const data = await this._billingAPI.cancelCurrentPlan();
return data;

View File

@ -10,13 +10,16 @@ import { BillingPlanManagers } from 'app/client/ui/BillingPlanManagers';
import {createForbiddenPage} from 'app/client/ui/errorPages';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {NEW_DEAL, showTeamUpgradeConfirmation} from 'app/client/ui/ProductUpgrades';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import { loadingSpinner } from 'app/client/ui2018/loaders';
import { NEW_DEAL, showTeamUpgradeConfirmation } from 'app/client/ui/ProductUpgrades';
import {colors} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal} from 'app/client/ui2018/modals';
import {BillingTask, IBillingCoupon} from 'app/common/BillingAPI';
import {displayPlanName, TEAM_FREE_PLAN, TEAM_PLAN} from 'app/common/Features';
import {capitalize} from 'app/common/gutil';
import {Organization} from 'app/common/UserAPI';
import {Disposable, dom, DomArg, IAttrObj, makeTestId, Observable} from 'grainjs';
@ -212,23 +215,27 @@ export class BillingPage extends Disposable {
const discountName = sub.discount && sub.discount.name;
const discountEnd = sub.discount && sub.discount.end_timestamp_ms;
const tier = discountName && discountName.includes(' Tier ');
const activePlanName = sub.activePlan?.nickname ?? this._appModel.planName;
const activePlanName = sub.activePlan?.nickname ??
displayPlanName[this._appModel.planName || ''] ?? this._appModel.planName;
const planName = tier ? discountName : activePlanName;
const appSumoInvoiced = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
const isPaidPlan = sub.billable;
// If subscription is cancelled, we need to create a new one using Stripe Checkout.
// If subscription is canceled, we need to create a new one using Stripe Checkout.
const canRenew = (sub.status === 'canceled' && isPaidPlan);
// We can upgrade only free team plan at this moment.
const canUpgrade = !canRenew && !isPaidPlan;
// And we can manage team plan that is not cancelled.
// And we can manage team plan that is not canceled.
const canManage = !canRenew && isPaidPlan;
const isCanceled = sub.status === 'canceled';
const wasTeam = this._appModel.planName === TEAM_PLAN && isCanceled && !validPlan;
return [
css.summaryFeatures(
validPlan && planName ? [
makeSummaryFeature(['You are subscribed to the ', planName, ' plan'])
] : [
makeSummaryFeature(['This team site is not in good standing'],
{ isBad: true }),
isCanceled ?
makeSummaryFeature(['You were subscribed to the ', planName, ' plan'], { isBad: true }) :
makeSummaryFeature(['This team site is not in good standing'], { isBad: true }),
],
// If the plan is changing, include the date the current plan ends
// and the plan that will be in effect afterwards.
@ -239,8 +246,7 @@ export class BillingPage extends Disposable {
] : null,
cancellingPlan && isPaidPlan ? [
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, your team site will become ', 'read-only',
' for one month, then removed'])
makeSummaryFeature(['On this date, your team site will become read-only'])
] : null,
moneyPlan?.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
@ -269,6 +275,9 @@ export class BillingPage extends Disposable {
),
!canManage ? null :
makeActionLink('Manage billing', 'Settings', this._model.getCustomerPortalUrl(), testId('portal-link')),
!wasTeam ? null :
makeActionButton('Downgrade plan', 'Settings',
() => this._confirmDowngradeToTeamFree(), testId('downgrade-free-link')),
!canRenew ? null :
makeActionLink('Renew subscription', 'Settings', this._model.renewPlan(), testId('renew-link')),
!canUpgrade ? null :
@ -295,6 +304,29 @@ export class BillingPage extends Disposable {
});
}
private _confirmDowngradeToTeamFree() {
confirmModal('Downgrade to Free Team Plan',
'Downgrade',
() => this._downgradeToTeamFree(),
dom('div', {style: `color: ${colors.dark}`}, testId('downgrade-confirm-modal'),
dom('div', 'Documents on free team plan are subject to the following limits. '
+'Any documents in excess of these limits will be put in read-only mode.'),
dom('ul',
dom('li', { style: 'margin-bottom: 0.6em'}, dom('strong', '5,000'), ' rows per document'),
dom('li', { style: 'margin-bottom: 0.6em'}, dom('strong', '10MB'), ' max document size'),
dom('li', 'API limit: ', dom('strong', '5k'), ' calls/day'),
)
),
);
}
private async _downgradeToTeamFree() {
// Perform the downgrade operation.
await this._model.downgradePlan(TEAM_FREE_PLAN);
// Refresh app model
this._appModel.topAppModel.initialize();
}
private _makeAppSumoFeature(name: string) {
// TODO: move AppSumo plan knowledge elsewhere.
let users = 0;

View File

@ -136,6 +136,7 @@ export interface BillingAPI {
createTeam(name: string, domain: string): Promise<string>;
upgrade(): Promise<string>;
cancelCurrentPlan(): Promise<void>;
downgradePlan(planName: string): Promise<void>;
renewPlan(): string;
customerPortal(): string;
}
@ -170,6 +171,12 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
});
}
public async downgradePlan(planName: string): Promise<void> {
await this.request(`${this._url}/api/billing/downgrade-plan`, {
method: 'POST',
body: JSON.stringify({ planName })
});
}
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
await this.request(`${this._url}/api/billing/settings`, {

View File

@ -68,11 +68,15 @@ export function canAddOrgMembers(features: Features): boolean {
return features.maxWorkspacesPerOrg !== 1;
}
export const FREE_PERSONAL_PLAN = 'starter';
export const TEAM_FREE_PLAN = 'teamFree';
export const TEAM_PLAN = 'team';
export const displayPlanName: { [key: string]: string } = {
[TEAM_FREE_PLAN]: 'Team Free',
[TEAM_PLAN]: 'Team'
} as const;
// Returns true if `product` is free.
export function isFreeProduct(product: Product): boolean {
return [FREE_PERSONAL_PLAN, TEAM_FREE_PLAN, 'Free'].includes(product?.name);