(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; getCustomerPortalUrl(): string;
// Renews plan (either by opening customer portal or creating Stripe Checkout session) // Renews plan (either by opening customer portal or creating Stripe Checkout session)
renewPlan(): string; 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 { export interface ISubscriptionModel extends IBillingSubscription {
@ -124,6 +127,10 @@ export class BillingModelImpl extends Disposable implements BillingModel {
return this._billingAPI.renewPlan(); return this._billingAPI.renewPlan();
} }
public async downgradePlan(planName: string): Promise<void> {
await this._billingAPI.downgradePlan(planName);
}
public async cancelCurrentPlan() { public async cancelCurrentPlan() {
const data = await this._billingAPI.cancelCurrentPlan(); const data = await this._billingAPI.cancelCurrentPlan();
return data; return data;

View File

@ -6,20 +6,23 @@ import {urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader'; import {AppHeader} from 'app/client/ui/AppHeader';
import {BillingForm, IFormData} from 'app/client/ui/BillingForm'; import {BillingForm, IFormData} from 'app/client/ui/BillingForm';
import * as css from 'app/client/ui/BillingPageCss'; import * as css from 'app/client/ui/BillingPageCss';
import { BillingPlanManagers } from 'app/client/ui/BillingPlanManagers'; import {BillingPlanManagers} from 'app/client/ui/BillingPlanManagers';
import { createForbiddenPage } from 'app/client/ui/errorPages'; import {createForbiddenPage} from 'app/client/ui/errorPages';
import { leftPanelBasic } from 'app/client/ui/LeftPanelCommon'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import { pagePanels } from 'app/client/ui/PagePanels'; import {pagePanels} from 'app/client/ui/PagePanels';
import { createTopBarHome } from 'app/client/ui/TopBar'; import {NEW_DEAL, showTeamUpgradeConfirmation} from 'app/client/ui/ProductUpgrades';
import { cssBreadcrumbs, cssBreadcrumbsLink, separator } from 'app/client/ui2018/breadcrumbs'; import {createTopBarHome} from 'app/client/ui/TopBar';
import { bigBasicButton, bigBasicButtonLink, bigPrimaryButton } from 'app/client/ui2018/buttons'; import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/breadcrumbs';
import { loadingSpinner } from 'app/client/ui2018/loaders'; import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import { NEW_DEAL, showTeamUpgradeConfirmation } from 'app/client/ui/ProductUpgrades'; import {colors} from 'app/client/ui2018/cssVars';
import { IconName } from 'app/client/ui2018/IconList'; import {IconName} from 'app/client/ui2018/IconList';
import { BillingTask, IBillingCoupon } from 'app/common/BillingAPI'; import {loadingSpinner} from 'app/client/ui2018/loaders';
import { capitalize } from 'app/common/gutil'; import {confirmModal} from 'app/client/ui2018/modals';
import { Organization } from 'app/common/UserAPI'; import {BillingTask, IBillingCoupon} from 'app/common/BillingAPI';
import { Disposable, dom, DomArg, IAttrObj, makeTestId, Observable } from 'grainjs'; 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';
const testId = makeTestId('test-bp-'); const testId = makeTestId('test-bp-');
const billingTasksNames = { const billingTasksNames = {
@ -212,23 +215,27 @@ export class BillingPage extends Disposable {
const discountName = sub.discount && sub.discount.name; const discountName = sub.discount && sub.discount.name;
const discountEnd = sub.discount && sub.discount.end_timestamp_ms; const discountEnd = sub.discount && sub.discount.end_timestamp_ms;
const tier = discountName && discountName.includes(' Tier '); 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 planName = tier ? discountName : activePlanName;
const appSumoInvoiced = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId; const appSumoInvoiced = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
const isPaidPlan = sub.billable; 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); const canRenew = (sub.status === 'canceled' && isPaidPlan);
// We can upgrade only free team plan at this moment. // We can upgrade only free team plan at this moment.
const canUpgrade = !canRenew && !isPaidPlan; 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 canManage = !canRenew && isPaidPlan;
const isCanceled = sub.status === 'canceled';
const wasTeam = this._appModel.planName === TEAM_PLAN && isCanceled && !validPlan;
return [ return [
css.summaryFeatures( css.summaryFeatures(
validPlan && planName ? [ validPlan && planName ? [
makeSummaryFeature(['You are subscribed to the ', planName, ' plan']) makeSummaryFeature(['You are subscribed to the ', planName, ' plan'])
] : [ ] : [
makeSummaryFeature(['This team site is not in good standing'], isCanceled ?
{ isBad: true }), 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 // If the plan is changing, include the date the current plan ends
// and the plan that will be in effect afterwards. // and the plan that will be in effect afterwards.
@ -239,8 +246,7 @@ export class BillingPage extends Disposable {
] : null, ] : null,
cancellingPlan && isPaidPlan ? [ cancellingPlan && isPaidPlan ? [
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]), makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, your team site will become ', 'read-only', makeSummaryFeature(['On this date, your team site will become read-only'])
' for one month, then removed'])
] : null, ] : null,
moneyPlan?.amount ? [ moneyPlan?.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`, makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
@ -269,6 +275,9 @@ export class BillingPage extends Disposable {
), ),
!canManage ? null : !canManage ? null :
makeActionLink('Manage billing', 'Settings', this._model.getCustomerPortalUrl(), testId('portal-link')), makeActionLink('Manage billing', 'Settings', this._model.getCustomerPortalUrl(), testId('portal-link')),
!wasTeam ? null :
makeActionButton('Downgrade plan', 'Settings',
() => this._confirmDowngradeToTeamFree(), testId('downgrade-free-link')),
!canRenew ? null : !canRenew ? null :
makeActionLink('Renew subscription', 'Settings', this._model.renewPlan(), testId('renew-link')), makeActionLink('Renew subscription', 'Settings', this._model.renewPlan(), testId('renew-link')),
!canUpgrade ? null : !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) { private _makeAppSumoFeature(name: string) {
// TODO: move AppSumo plan knowledge elsewhere. // TODO: move AppSumo plan knowledge elsewhere.
let users = 0; let users = 0;

View File

@ -136,6 +136,7 @@ export interface BillingAPI {
createTeam(name: string, domain: string): Promise<string>; createTeam(name: string, domain: string): Promise<string>;
upgrade(): Promise<string>; upgrade(): Promise<string>;
cancelCurrentPlan(): Promise<void>; cancelCurrentPlan(): Promise<void>;
downgradePlan(planName: string): Promise<void>;
renewPlan(): string; renewPlan(): string;
customerPortal(): 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> { public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
await this.request(`${this._url}/api/billing/settings`, { await this.request(`${this._url}/api/billing/settings`, {

View File

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