gristlabs_grist-core/app/client/ui/BillingPage.ts

689 lines
26 KiB
TypeScript
Raw Normal View History

import {buildHomeBanners} from 'app/client/components/Banners';
import {beaconOpenMessage} from 'app/client/lib/helpScout';
import {AppModel, reportError} from 'app/client/models/AppModel';
import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel';
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 {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 = {
signUp: 'Sign Up', // task for payment page
signUpLite: 'Complete Sign Up', // task for payment page
updateDomain: 'Update Name', // task for summary page
cancelPlan: 'Cancel plan', // this is not a task, but a sub page
upgraded: 'Account',
};
/**
* Creates the billing page where users can manage their subscription and payment card.
*/
export class BillingPage extends Disposable {
private _model: BillingModel = new BillingModelImpl(this._appModel);
private _form: BillingForm | undefined = undefined;
private _formData: IFormData = {};
private _showConfirmPage: Observable<boolean> = Observable.create(this, false);
private _isSubmitting: Observable<boolean> = Observable.create(this, false);
constructor(private _appModel: AppModel) {
super();
// TODO: remove once NEW_DEAL is there. Execute for side effect
void NEW_DEAL();
this._appModel.refreshOrgUsage().catch(reportError);
}
// Exposed for tests.
public testBuildPaymentPage() {
return this._buildPaymentPage();
}
public buildDom() {
return dom.domComputed(this._model.isUnauthorized, (isUnauthorized) => {
if (isUnauthorized) {
return createForbiddenPage(this._appModel,
'Only billing plan managers may view billing account information. Plan managers may ' +
'be added in the billing summary by existing plan managers.');
} else {
const panelOpen = Observable.create(this, false);
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen,
hideOpener: true,
header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel),
content: leftPanelBasic(this._appModel, panelOpen),
},
headerMain: this._createTopBarBilling(),
contentTop: buildHomeBanners(this._appModel),
contentMain: this._buildCurrentPageDom()
});
}
});
}
/**
* Builds the contentMain dom for the current billing page.
*/
private _buildCurrentPageDom() {
const page = css.billingWrapper(
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (!subpage) {
return this._buildSummaryPage();
} else if (subpage === 'payment') {
return this._buildPaymentPage();
}
})
);
if (this._model.currentTask.get() === 'upgraded') {
urlState().pushUrl({params: {}}, { replace: true }).catch(() => {});
showTeamUpgradeConfirmation(this);
}
return page;
}
private _buildSummaryPage() {
const org = this._appModel.currentOrg;
// Fetch plan and card data.
this._model.fetchData(true).catch(reportError);
return css.billingPage(
dom.domComputed(this._model.currentTask, (task) => {
const pageText = task ? billingTasksNames[task] : 'Account';
return [
css.cardBlock(
css.billingHeader(pageText),
task !== 'updateDomain' ? [
dom.domComputed(this._model.subscription, () => [
this._buildDomainSummary(org ?? {}),
]),
// If this is not a personal org, create the plan manager list dom.
org && !org.owner ? dom.frag(
css.billingHeader('Plan Managers', { style: 'margin: 32px 0 16px 0;' }),
css.billingHintText(
'You may add additional billing contacts (for example, your accounting department). ' +
'All billing-related emails will be sent to this list of contacts.'
),
dom.create(BillingPlanManagers, this._model, org, this._appModel.currentValidUser)
) : null
] : dom.domComputed(this._showConfirmPage, (showConfirm) => {
if (showConfirm) {
return [
this._buildConfirm(this._formData),
this._buildButtons(pageText)
];
} else {
return this._buildForms(org, task);
}
})
),
css.summaryBlock(
css.billingHeader('Billing Summary'),
this._buildSubscriptionSummary(),
)
];
})
);
}
// PRIVATE - exposed for tests
private _buildPaymentPage() {
const org = this._appModel.currentOrg;
// Fetch plan and card data if not already present.
this._model.fetchData().catch(this._model.reportBlockingError);
return css.billingPage(
dom.maybe(this._model.currentTask, task => {
const pageText = billingTasksNames[task];
return [
css.cardBlock(
css.billingHeader(pageText),
dom.domComputed((use) => {
const err = use(this._model.error);
if (err) {
return css.errorBox(err, dom('br'), dom('br'), reportLink(this._appModel, "Report problem"));
}
const sub = use(this._model.subscription);
if (!sub) {
return css.spinnerBox(loadingSpinner());
}
if (task === 'cancelPlan') {
// If the selected plan is free, the user is cancelling their subscription.
return [
css.paymentBlock(
'On the subscription end date, your team site will remain available in ' +
'read-only mode for one month.',
),
css.paymentBlock(
'After the one month grace period, your team site will be removed along ' +
'with all documents inside.'
),
css.paymentBlock('Are you sure you would like to cancel the subscription?'),
this._buildButtons('Cancel Subscription')
];
} else { // tasks - signUpLite
return dom.domComputed(this._showConfirmPage, (showConfirm) => {
if (showConfirm) {
return [
this._buildConfirm(this._formData),
this._buildButtons(pageText)
];
} else {
return this._buildForms(org, task);
}
});
}
})
),
css.summaryBlock(
css.billingHeader('Summary'),
css.summaryFeatures(
this._buildPaymentSummary(task),
testId('summary')
)
)
];
})
);
}
private _buildSubscriptionSummary() {
return dom.domComputed(this._model.subscription, sub => {
if (!sub) {
return css.spinnerBox(loadingSpinner());
} else {
const moneyPlan = sub.upcomingPlan || sub.activePlan;
const changingPlan = sub.upcomingPlan && sub.upcomingPlan.amount > 0;
const cancellingPlan = sub.upcomingPlan && sub.upcomingPlan.amount === 0;
const validPlan = sub.isValidPlan;
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 ??
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 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 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'])
] : [
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.
changingPlan && isPaidPlan ? [
makeSummaryFeature(['Your current plan ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, you will be subscribed to the ',
sub.upcomingPlan?.nickname ?? '-', ' plan'])
] : null,
cancellingPlan && isPaidPlan ? [
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, your team site will become read-only'])
] : null,
moneyPlan?.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
` member${sub.userCount !== 1 ? 's' : ''}`]),
tier ? this._makeAppSumoFeature(discountName) : null,
// Currently the subtotal is misleading and scary when tiers are in effect.
// In this case, for now, just report what will be invoiced.
!tier ? makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `,
getPriceString(moneyPlan.amount * sub.userCount)]) : null,
(discountName && !tier) ?
makeSummaryFeature([
`You receive the `,
discountName,
...(discountEnd !== null ? [' (until ', dateFmtFull(discountEnd), ')'] : []),
]) :
null,
// When on a free trial, Stripe reports trialEnd time, but it seems to always
// match periodEnd for a trialing subscription, so we just use that.
sub.isInTrial ? makeSummaryFeature(['Your free trial ends on ', dateFmtFull(sub.periodEnd)]) : null,
makeSummaryFeature([`Your next invoice is `, getPriceString(sub.nextTotal),
' on ', dateFmt(sub.periodEnd)]),
] : null,
appSumoInvoiced ? makeAppSumoLink(appSumoInvoiced) : null,
getSubscriptionProblem(sub),
testId('summary')
),
!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 :
makeActionButton('Upgrade subscription', 'Settings',
() => this._appModel.showUpgradeModal(), testId('upgrade-link')),
!(validPlan && planName && isPaidPlan && !cancellingPlan) ? null :
makeActionLink(
'Cancel subscription',
'Settings',
urlState().setLinkUrl({
billing: 'payment',
params: {
billingTask: 'cancelPlan'
}
}),
testId('cancel-subscription')
),
(sub.lastInvoiceUrl && sub.activeSubscription ?
makeActionLink('View last invoice', 'Page', sub.lastInvoiceUrl, testId('invoice-link'))
: null
),
];
}
});
}
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;
switch (name) {
case 'AppSumo Tier 1':
users = 1;
break;
case 'AppSumo Tier 2':
users = 3;
break;
case 'AppSumo Tier 3':
users = 8;
break;
}
if (users > 0) {
return makeSummaryFeature([`Your AppSumo plan covers `,
`${users}`,
` member${users > 1 ? 's' : ''}`]);
}
return null;
}
private _buildForms(org: Organization | null, task: BillingTask) {
const isTeamSite = org && org.billingAccount && !org.billingAccount.individual;
const currentSettings = this._formData.settings ?? {
name: org!.name,
domain: org?.domain?.startsWith('o-') ? undefined : org?.domain || undefined,
};
const pageText = billingTasksNames[task];
// If there is an immediate charge required, require re-entering the card info.
// Show all forms on sign up.
this._form = new BillingForm(
org,
this._model,
{
settings: ['signUpLite', 'updateDomain'].includes(task),
domain: ['signUpLite', 'updateDomain'].includes(task)
},
{
settings: currentSettings,
}
);
return dom('div',
dom.onDispose(() => {
if (this._form) {
this._form.dispose();
this._form = undefined;
}
}),
isTeamSite ? this._buildDomainSummary(currentSettings ?? {}) : null,
this._form.buildDom(),
this._buildButtons(pageText)
);
}
private _buildConfirm(formData: IFormData) {
const settings = formData.settings || null;
return [
this._buildDomainConfirmation(settings ?? {}, false),
];
}
private _createTopBarBilling() {
const org = this._appModel.currentOrg;
return dom.frag(
cssBreadcrumbs({ style: 'margin-left: 16px;' },
cssBreadcrumbsLink(
urlState().setLinkUrl({}),
'Home',
testId('home')
),
separator(' / '),
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (subpage) {
return [
// Prevent navigating to the summary page if these pages are not associated with an org.
org && !org.owner ? cssBreadcrumbsLink(
urlState().setLinkUrl({ billing: 'billing' }),
'Billing',
testId('billing')
) : dom('span', 'Billing'),
separator(' / '),
dom('span', capitalize(subpage))
];
} else {
return dom('span', 'Billing');
}
})
),
createTopBarHome(this._appModel),
);
}
private _buildDomainConfirmation(org: { name?: string | null, domain?: string | null }, showEdit: boolean = true) {
return css.summaryItem(
css.summaryHeader(
css.billingBoldText('Team Info'),
),
org?.name ? [
css.summaryRow(
{ style: 'margin-bottom: 0.6em' },
css.billingText(`Your team name: `,
dom('span', { style: 'font-weight: bold' }, org?.name, testId('org-name')),
),
)
] : null,
org?.domain ? [
css.summaryRow(
css.billingText(`Your team site URL: `,
dom('span', { style: 'font-weight: bold' }, org?.domain),
`.getgrist.com`,
testId('org-domain')
),
showEdit ? css.billingTextBtn(css.billingIcon('Settings'), 'Change',
urlState().setLinkUrl({
billing: 'billing',
params: { billingTask: 'updateDomain' }
}),
testId('update-domain')
) : null
)
] : null
);
}
private _buildDomainSummary(org: { name?: string | null, domain?: string | null }, showEdit: boolean = true) {
const task = this._model.currentTask.get();
if (task === 'signUpLite' || task === 'updateDomain') { return null; }
return this._buildDomainConfirmation(org, showEdit);
}
// Summary panel for payment subpage.
private _buildPaymentSummary(task: BillingTask) {
if (task === 'signUpLite') {
return this._buildSubscriptionSummary();
} else if (task === 'updateDomain') {
return makeSummaryFeature('You are updating the site name and domain');
} else if (task === 'cancelPlan') {
return dom.domComputed(this._model.subscription, sub => {
return [
makeSummaryFeature(['You are cancelling the subscription']),
sub ? makeSummaryFeature(['Your subscription will end on ', dateFmt(sub.periodEnd)]) : null
];
});
} else {
return null;
}
}
private _buildButtons(submitText: string) {
const task = this._model.currentTask.get();
this._isSubmitting.set(false); // Reset status on build.
return css.paymentBtnRow(
task !== 'signUpLite' ? bigBasicButton('Back',
dom.on('click', () => window.history.back()),
dom.show((use) => !use(this._showConfirmPage)),
dom.boolAttr('disabled', this._isSubmitting),
testId('back')
) : null,
task !== 'cancelPlan' ? bigBasicButtonLink('Edit',
dom.show(this._showConfirmPage),
dom.on('click', () => this._showConfirmPage.set(false)),
dom.boolAttr('disabled', this._isSubmitting),
testId('edit')
) : null,
bigPrimaryButton({ style: 'margin-left: 10px;' },
dom.text(submitText),
dom.boolAttr('disabled', this._isSubmitting),
dom.on('click', () => this._doSubmit(task)),
testId('submit')
)
);
}
// Submit the active form.
private async _doSubmit(task?: BillingTask): Promise<void> {
if (this._isSubmitting.get()) { return; }
this._isSubmitting.set(true);
try {
if (task === 'cancelPlan') {
await this._model.cancelCurrentPlan();
this._showConfirmPage.set(false);
this._formData = {};
await urlState().pushUrl({ billing: 'billing', params: undefined });
return;
}
// If the form is built, fetch the form data.
if (this._form) {
this._formData = await this._form.getFormData();
}
// In general, submit data to the server.
if (task === 'updateDomain' || this._showConfirmPage.get()) {
await this._model.submitPaymentPage(this._formData);
// On submit, reset confirm page and form data.
this._showConfirmPage.set(false);
this._formData = {};
} else {
this._showConfirmPage.set(true);
this._isSubmitting.set(false);
}
} catch (err) {
// Note that submitPaymentPage are responsible for reporting errors.
// On failure the submit button isSubmitting state should be returned to false.
if (!this.isDisposed()) {
this._isSubmitting.set(false);
this._showConfirmPage.set(false);
// Focus the first element with an error.
this._form?.focusOnError();
}
}
}
}
const statusText: { [key: string]: string } = {
incomplete: 'incomplete',
incomplete_expired: 'incomplete',
past_due: 'past due',
canceled: 'canceled',
unpaid: 'unpaid',
};
function getSubscriptionProblem(sub: ISubscriptionModel) {
const text = sub.status && statusText[sub.status];
if (!text) { return null; }
const result = [['Your subscription is ', text]];
if (sub.lastChargeError) {
const when = sub.lastChargeTime ? `on ${timeFmt(sub.lastChargeTime)} ` : '';
result.push([`Last charge attempt ${when} failed: ${sub.lastChargeError}`]);
}
return result.map(msg => makeSummaryFeature(msg, { isBad: true }));
}
interface PriceOptions {
taxRate?: number;
coupon?: IBillingCoupon;
refund?: number;
}
const defaultPriceOptions: PriceOptions = {
taxRate: 0,
coupon: undefined,
refund: 0,
};
function getPriceString(priceCents: number, options = defaultPriceOptions): string {
const { taxRate = 0, coupon, refund } = options;
if (coupon) {
if (coupon.amount_off) {
priceCents -= coupon.amount_off;
} else if (coupon.percent_off) {
priceCents -= (priceCents * (coupon.percent_off / 100));
}
}
if (refund) {
priceCents -= refund;
}
// Make sure we never display negative prices.
priceCents = Math.max(0, priceCents);
// TODO: Add functionality for other currencies.
return ((priceCents / 100) * (taxRate + 1)).toLocaleString('en-US', {
style: "currency",
currency: "USD",
minimumFractionDigits: 2
});
}
// Include a precise link back to AppSumo for changing plans.
function makeAppSumoLink(invoiceId: string) {
return dom('div',
css.billingTextBtn({ style: 'margin: 10px 0;' },
cssBreadcrumbsLink(
css.billingIcon('Plus'), 'Change your AppSumo plan',
{
href: `https://appsumo.com/account/redemption/${invoiceId}/#change-plan`,
target: '_blank'
},
testId('appsumo-link')
)
));
}
/**
* Make summary feature to include in:
* - Plan cards for describing features of the plan.
* - Summary lists describing what is being paid for and how much will be charged.
* - Summary lists describing the current subscription.
*
* Accepts text as an array where strings at every odd numbered index are bolded for emphasis.
* If isMissingFeature is set, no text is bolded and the optional attribute object is not applied.
* If isBad is set, a cross is used instead of a tick
*/
function makeSummaryFeature(
text: string | string[],
options: { isMissingFeature?: boolean, isBad?: boolean, attr?: IAttrObj } = {}
) {
const textArray = Array.isArray(text) ? text : [text];
if (options.isMissingFeature) {
return css.summaryMissingFeature(
textArray,
testId('summary-line')
);
} else {
return css.summaryFeature(options.attr,
options.isBad ? css.billingBadIcon('CrossBig') : css.billingIcon('Tick'),
textArray.map((str, i) => (i % 2) ? css.focusText(str) : css.summaryText(str)),
testId('summary-line')
);
}
}
function makeActionLink(text: string, icon: IconName, url: DomArg<HTMLElement>, ...args: DomArg<HTMLElement>[]) {
return dom('div',
css.billingTextBtn(
{ style: 'margin: 10px 0;' },
cssBreadcrumbsLink(
css.billingIcon(icon), text,
typeof url === 'string' ? { href: url } : url,
...args,
)
)
);
}
function makeActionButton(text: string, icon: IconName, handler: () => any, ...args: DomArg<HTMLElement>[]) {
return css.billingTextBtn(
{ style: 'margin: 10px 0;' },
css.billingIcon(icon), text,
dom.on('click', handler),
...args
);
}
function reportLink(appModel: AppModel, text: string): HTMLElement {
return dom('a', { href: '#' }, text,
dom.on('click', (ev) => { ev.preventDefault(); beaconOpenMessage({ appModel }); })
);
}
function dateFmt(timestamp: number | null): string {
if (!timestamp) { return "unknown"; }
const date = new Date(timestamp);
if (date.getFullYear() !== new Date().getFullYear()) {
return dateFmtFull(timestamp);
}
return new Date(timestamp).toLocaleDateString('default', { month: 'long', day: 'numeric' });
}
function dateFmtFull(timestamp: number | null): string {
if (!timestamp) { return "unknown"; }
return new Date(timestamp).toLocaleDateString('default', { month: 'short', day: 'numeric', year: 'numeric' });
}
function timeFmt(timestamp: number): string {
return new Date(timestamp).toLocaleString('default',
{ month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' });
}