diff --git a/app/client/models/BillingModel.ts b/app/client/models/BillingModel.ts index d14c4e60..0349bbea 100644 --- a/app/client/models/BillingModel.ts +++ b/app/client/models/BillingModel.ts @@ -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; } 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 { + await this._billingAPI.downgradePlan(planName); + } + public async cancelCurrentPlan() { const data = await this._billingAPI.cancelCurrentPlan(); return data; diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index 0fc55cc9..e7943233 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -6,20 +6,23 @@ import {urlState} from 'app/client/models/gristUrlState'; import {AppHeader} from 'app/client/ui/AppHeader'; import {BillingForm, IFormData} from 'app/client/ui/BillingForm'; import * as css from 'app/client/ui/BillingPageCss'; -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 { 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 { IconName } from 'app/client/ui2018/IconList'; -import { BillingTask, IBillingCoupon } from 'app/common/BillingAPI'; -import { capitalize } from 'app/common/gutil'; -import { Organization } from 'app/common/UserAPI'; -import { Disposable, dom, DomArg, IAttrObj, makeTestId, Observable } from 'grainjs'; +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 {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'; const testId = makeTestId('test-bp-'); const billingTasksNames = { @@ -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; diff --git a/app/common/BillingAPI.ts b/app/common/BillingAPI.ts index 54e40cf3..279271cd 100644 --- a/app/common/BillingAPI.ts +++ b/app/common/BillingAPI.ts @@ -136,6 +136,7 @@ export interface BillingAPI { createTeam(name: string, domain: string): Promise; upgrade(): Promise; cancelCurrentPlan(): Promise; + downgradePlan(planName: string): Promise; renewPlan(): string; customerPortal(): string; } @@ -170,6 +171,12 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI { }); } + public async downgradePlan(planName: string): Promise { + await this.request(`${this._url}/api/billing/downgrade-plan`, { + method: 'POST', + body: JSON.stringify({ planName }) + }); + } public async updateSettings(settings?: IBillingOrgSettings): Promise { await this.request(`${this._url}/api/billing/settings`, { diff --git a/app/common/Features.ts b/app/common/Features.ts index 4459d8ae..dd28b4bb 100644 --- a/app/common/Features.ts +++ b/app/common/Features.ts @@ -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);