gristlabs_grist-core/app/client/ui/BillingPage.ts
Paul Fitzpatrick 14f7e30e6f (core) add users.options.isConsultant flag, and omit such users from billing
Summary:
This adds an optional `isConsultant` flag to `users.options`, and an endpoint that allows the support user to turn it on or off. Users marked as consultants are not counted as billable members. Follows the example of existing `allowGoogleLogin` option.

Billable members are counted when members are added or removed from a site. Changing the `isConsultant` flag has no immediate or retroactive effect on billing. The number of users in stripe is now set unconditionally, rather than only when it has changed.

Notifications to billing managers are not aware of this billing nuance, but continue to report user counts that include consultants. The notifications link users to the billing page.

Test Plan: extended test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: anaisconce, jarek

Differential Revision: https://phab.getgrist.com/D3362
2022-04-11 10:26:31 -04:00

943 lines
38 KiB
TypeScript

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 {getLoginUrl, 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 {confirmModal} from 'app/client/ui2018/modals';
import {BillingSubPage, BillingTask, IBillingAddress, IBillingCard, IBillingCoupon,
IBillingPlan} from 'app/common/BillingAPI';
import {capitalize} from 'app/common/gutil';
import {Organization} from 'app/common/UserAPI';
import {Disposable, dom, IAttrObj, IDomArgs, makeTestId, Observable} from 'grainjs';
const testId = makeTestId('test-bp-');
const taskActions = {
signUp: 'Sign Up',
updatePlan: 'Update Plan',
addCard: 'Add Payment Method',
updateCard: 'Update Payment Method',
updateAddress: 'Update Address',
signUpLite: 'Complete Sign Up',
updateDomain: 'Update Name',
};
/**
* Creates the billing page where a user can manager their subscription and payment card.
*/
export class BillingPage extends Disposable {
private readonly _model: BillingModel = new BillingModelImpl(this._appModel);
private _form: BillingForm|undefined = undefined;
private _formData: IFormData = {};
// Indicates whether the payment page is showing the confirmation page or the data entry form.
// If _showConfirmation includes the entered form data, the confirmation page is shown.
// A null value indicates the data entry form is being shown.
private readonly _showConfirmPage: Observable<boolean> = Observable.create(this, false);
// Indicates that the payment page submit button has been clicked to prevent repeat requests.
private readonly _isSubmitting: Observable<boolean> = Observable.create(this, false);
constructor(private _appModel: AppModel) {
super();
}
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(),
contentMain: this.buildCurrentPageDom()
});
}
});
}
/**
* Builds the contentMain dom for the current billing page.
*/
public buildCurrentPageDom() {
return css.billingWrapper(
dom.domComputed(this._model.currentSubpage, (subpage) => {
if (!subpage) {
return this.buildSummaryPage();
} else if (subpage === 'payment') {
return this.buildPaymentPage();
} else if (subpage === 'plans') {
return this.buildPlansPage();
}
})
);
}
public buildSummaryPage() {
const org = this._appModel.currentOrg;
// Fetch plan and card data.
this._model.fetchData(true).catch(reportError);
return css.billingPage(
css.cardBlock(
css.billingHeader('Account'),
dom.domComputed(this._model.subscription, sub => [
this._buildDomainSummary(org && org.domain),
this._buildCompanySummary(org && org.name, sub ? (sub.address || {}) : null),
dom.domComputed(this._model.card, card =>
this._buildCardSummary(card, !sub, [
css.billingTextBtn(css.billingIcon('Settings'), 'Change',
urlState().setLinkUrl({
billing: 'payment',
params: { billingTask: 'updateCard' }
}),
testId('update-card')
),
css.billingTextBtn(css.billingIcon('Remove'), 'Remove',
dom.on('click', () => this._showRemoveCardModal()),
testId('remove-card')
)
])
)
]),
// 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
),
css.summaryBlock(
css.billingHeader('Billing Summary'),
this.buildSubscriptionSummary(),
)
);
}
public buildSubscriptionSummary() {
return dom.maybe(this._model.subscription, sub => {
const plans = this._model.plans.get();
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 planId = validPlan ? sub.activePlan.id : sub.lastPlanId;
// If on a "Tier" coupon, present information differently, emphasizing the coupon
// name and minimizing the plan.
const discountName = sub.discount && sub.discount.name;
const discountEnd = sub.discount && sub.discount.end_timestamp_ms;
const tier = discountName && discountName.includes(' Tier ');
const planName = tier ? discountName! : sub.activePlan.nickname;
const invoiceId = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
return [
css.summaryFeatures(
validPlan ? [
makeSummaryFeature(['You are subscribed to the ', planName, ' plan']),
] : [
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 ? [
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 ? [
makeSummaryFeature(['Your subscription ends on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature(['On this date, your team site will become ', 'read-only',
' for one month, then removed'])
] : null,
moneyPlan.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
` member${sub.userCount !== 1 ? 's' : ''}`]),
tier ? this.buildAppSumoPlanNotes(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,
invoiceId ? this.buildAppSumoLink(invoiceId) : null,
getSubscriptionProblem(sub),
testId('summary')
),
(sub.lastInvoiceUrl ?
dom('div',
css.billingTextBtn({ style: 'margin: 10px 0;' },
cssBreadcrumbsLink(
css.billingIcon('Page'), 'View last invoice',
{ href: sub.lastInvoiceUrl, target: '_blank' },
testId('invoice-link')
)
)
) :
null
),
(moneyPlan.amount === 0 && planId) ? css.billingTextBtn(
{ style: 'margin: 10px 0;' },
// If the plan was cancellled, make the text indicate that changing the plan will
// renew the subscription (abort the cancellation).
css.billingIcon('Settings'), 'Renew subscription',
urlState().setLinkUrl({
billing: 'payment',
params: {
billingTask: 'updatePlan',
billingPlan: planId
}
}),
testId('update-plan')
) : null,
// Do not show the cancel subscription option if it was already cancelled.
plans.length > 0 && moneyPlan.amount > 0 ? css.billingTextBtn(
{ style: 'margin: 10px 0;' },
css.billingIcon('Settings'), 'Cancel subscription',
urlState().setLinkUrl({
billing: 'payment',
params: {
billingTask: 'updatePlan',
billingPlan: plans[0].id
}
}),
testId('cancel-subscription')
) : null
];
});
}
public buildAppSumoPlanNotes(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;
}
// Include a precise link back to AppSumo for changing plans.
public buildAppSumoLink(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')
)
));
}
public buildPlansPage() {
// Fetch plan and card data if not already present.
this._model.fetchData().catch(reportError);
return css.plansPage(
css.billingHeader('Choose a plan'),
css.billingText('Give your team the features they need to succeed'),
this._buildPlanCards()
);
}
public buildPaymentPage() {
const org = this._appModel.currentOrg;
const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual;
// 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 = taskActions[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);
const card = use(this._model.card);
const newPlan = use(this._model.signupPlan);
if (newPlan && newPlan.amount === 0) {
// 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._buildPaymentBtns('Cancel Subscription')
];
} else if (sub && card && newPlan && sub.activePlan && newPlan.amount <= sub.activePlan.amount) {
// If the user already has a card entered and the plan costs less money than
// the current plan, show the card summary only (no payment required yet)
return [
isSharedOrg ? this._buildDomainSummary(org && org.domain) : null,
this._buildCardSummary(card, !sub, [
css.billingTextBtn(css.billingIcon('Settings'), 'Update Card',
// Clear the fetched card to display the card input form.
dom.on('click', () => this._model.card.set(null)),
testId('update-card')
)
]),
this._buildPaymentBtns(pageText)
];
} else {
return dom.domComputed(this._showConfirmPage, (showConfirm) => {
if (showConfirm) {
return [
this._buildPaymentConfirmation(this._formData),
this._buildPaymentBtns(pageText)
];
} else if (!sub) {
return css.spinnerBox(loadingSpinner());
} else if (!newPlan && (task === 'signUp' || task === 'updatePlan')) {
return css.errorBox('Unknown plan selected. Please check the URL, or ',
reportLink(this._appModel, 'report this issue'), '.');
} else {
return this._buildBillingForm(org, sub.address, task);
}
});
}
})
),
css.summaryBlock(
css.billingHeader('Summary'),
css.summaryFeatures(
this._buildPaymentSummary(task),
testId('summary')
)
)
];
})
);
}
private _buildBillingForm(org: Organization|null, address: IBillingAddress|null, task: BillingTask) {
const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual;
const currentSettings = isSharedOrg ? {
name: org!.name,
domain: org?.domain?.startsWith('o-') ? undefined : org?.domain || undefined,
} : this._formData.settings;
const currentAddress = address || this._formData.address;
const pageText = taskActions[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,
{
payment: ['signUp', 'updatePlan', 'addCard', 'updateCard'].includes(task),
discount: ['signUp'].includes(task),
address: ['signUp', 'updateAddress'].includes(task),
settings: ['signUp', 'signUpLite', 'updateAddress', 'updateDomain'].includes(task),
domain: ['signUp', 'signUpLite', 'updateDomain'].includes(task)
},
{
address: currentAddress,
settings: currentSettings,
card: this._formData.card,
coupon: this._formData.coupon
}
);
return dom('div',
dom.onDispose(() => {
if (this._form) {
this._form.dispose();
this._form = undefined;
}
}),
isSharedOrg ? this._buildDomainSummary(org && org.domain) : null,
this._form.buildDom(),
this._buildPaymentBtns(pageText)
);
}
private _buildPaymentConfirmation(formData: IFormData) {
const settings = formData.settings || null;
return [
this._buildDomainSummary(settings && settings.domain, false),
this._buildCompanySummary(settings && settings.name, formData.address || null, false),
this._buildCardSummary(formData.card || null)
];
}
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 _buildPlanCards() {
const org = this._appModel.currentOrg;
const isSharedOrg = org && org.billingAccount && !org.billingAccount.individual;
const attr = {style: 'margin: 12px 0 12px 0;'}; // Feature attributes
return css.plansContainer(
dom.maybe(this._model.plans, (plans) => {
// Do not show the free plan inside the paid org plan options.
return plans.filter(plan => !isSharedOrg || plan.amount > 0).map(plan => {
const priceStr = plan.amount === 0 ? 'Free' : getPriceString(plan.amount);
const meta = plan.metadata;
const maxDocs = meta.maxDocs ? `up to ${meta.maxDocs}` : `unlimited`;
const maxUsers = meta.maxUsersPerDoc ?
`Share with ${meta.maxUsersPerDoc} collaborators per doc` :
`Share and collaborate with any number of team members`;
return css.planBox(
css.billingHeader(priceStr, { style: `display: inline-block;` }),
css.planInterval(plan.amount === 0 ? '' : `/ user / ${plan.interval}`),
css.billingSubHeader(plan.nickname),
makeSummaryFeature(`Create ${maxDocs} docs`, {attr}),
makeSummaryFeature(maxUsers, {attr}),
makeSummaryFeature('Workspaces to organize docs and users', {
isMissingFeature: !meta.workspaces,
attr
}),
makeSummaryFeature(`Access to support`, {
isMissingFeature: !meta.supportAvailable,
attr
}),
makeSummaryFeature(`Unthrottled API access`, {
isMissingFeature: !meta.unthrottledApi,
attr
}),
makeSummaryFeature(`Custom Grist subdomain`, {
isMissingFeature: !meta.customSubdomain,
attr
}),
plan.trial_period_days ? makeSummaryFeature(['', `${plan.trial_period_days} day free trial`],
{attr}) : css.summarySpacer(),
// Add the upgrade buttons once the user plan information has loaded
dom.domComputed(this._model.subscription, sub => {
const activePrice = sub ? sub.activePlan.amount : 0;
const selectedPlan = sub && (sub.upcomingPlan || sub.activePlan);
// URL state for the payment page to update the plan or sign up.
const payUrlState = {
billing: 'payment' as BillingSubPage,
params: {
billingTask: activePrice > 0 ? 'updatePlan' : 'signUp' as BillingTask,
billingPlan: plan.id
}
};
if (!this._appModel.currentValidUser && plan.amount === 0) {
// If the user is not logged in and selects the free plan, provide a login link that
// redirects back to the free org.
return css.upgradeBtn('Sign up',
{href: getLoginUrl()},
testId('plan-btn')
);
} else if ((!selectedPlan && plan.amount === 0) || (selectedPlan && plan.id === selectedPlan.id)) {
return css.currentBtn('Current plan',
testId('plan-btn')
);
} else {
// Sign up / update plan.
// Show 'Create' if this is not a paid org to indicate that an org will be created.
const upgradeText = isSharedOrg ? 'Upgrade' : 'Create team site';
return css.upgradeBtn(plan.amount > activePrice ? upgradeText : 'Select',
urlState().setLinkUrl(payUrlState),
testId('plan-btn')
);
}
}),
testId('plan')
);
});
})
);
}
private _buildDomainSummary(domain: string|null, showEdit: boolean = true) {
const task = this._model.currentTask.get();
if (task === 'signUpLite' || task === 'updateDomain') { return null; }
return css.summaryItem(
css.summaryHeader(
css.billingBoldText('Billing Info'),
),
domain ? [
css.summaryRow(
css.billingText(`Your team site URL: `,
dom('span', {style: 'font-weight: bold'}, domain),
`.getgrist.com`,
testId('org-domain')
),
showEdit ? css.billingTextBtn(css.billingIcon('Settings'), 'Change',
urlState().setLinkUrl({
billing: 'payment',
params: { billingTask: 'updateDomain' }
}),
testId('update-domain')
) : null
)
] : null
);
}
private _buildCompanySummary(orgName: string|null, address: Partial<IBillingAddress>|null, showEdit: boolean = true) {
return css.summaryItem({style: 'min-height: 118px;'},
css.summaryHeader(
css.billingBoldText(`Company Name & Address`),
showEdit ? css.billingTextBtn(css.billingIcon('Settings'), 'Change',
urlState().setLinkUrl({
billing: 'payment',
params: { billingTask: 'updateAddress' }
}),
testId('update-address')
) : null
),
orgName && css.summaryRow(
css.billingText(orgName,
testId('org-name')
)
),
address ? [
css.summaryRow(
css.billingText(address.line1,
testId('company-address-1')
)
),
address.line2 ? css.summaryRow(
css.billingText(address.line2,
testId('company-address-2')
)
) : null,
css.summaryRow(
css.billingText(formatCityStateZip(address),
testId('company-address-3')
)
),
address.country ? css.summaryRow(
// This show a 2-letter country code (e.g. "US" or "DE"). This seems fine.
css.billingText(address.country,
testId('company-address-country')
)
) : null,
] : css.billingHintText('Fetching address...')
);
}
private _buildCardSummary(card: IBillingCard|null, fetching?: boolean, btns?: IDomArgs) {
if (fetching) {
// If the subscription data has not yet been fetched.
return css.summaryItem({style: 'min-height: 102px;'},
css.summaryHeader(
css.billingBoldText(`Payment Card`),
),
css.billingHintText('Fetching card preview...')
);
} else if (card) {
// There is a card attached to the account.
const brand = card.brand ? `${card.brand.toUpperCase()} ` : '';
return css.summaryItem(
css.summaryHeader(
css.billingBoldText(
// The header indicates the card type (Credit/Debit/Prepaid/Unknown)
`${capitalize(card.funding || 'payment')} Card`,
testId('card-funding')
),
btns
),
css.billingText(card.name,
testId('card-name')
),
css.billingText(`${brand}**** **** **** ${card.last4}`,
testId('card-preview')
)
);
} else {
return css.summaryItem(
css.summaryHeader(
css.billingBoldText(`Payment Card`),
css.billingTextBtn(css.billingIcon('Settings'), 'Add',
urlState().setLinkUrl({
billing: 'payment',
params: { billingTask: 'addCard' }
}),
testId('add-card')
),
),
// TODO: Warn user when a payment method will be required and decide
// what happens if it is not added.
css.billingText('Your account has no payment method', testId('card-preview'))
);
}
}
// Builds the list of summary items indicating why the user is being prompted with
// the payment method page and what will happen when the card information is submitted.
private _buildPaymentSummary(task: BillingTask) {
if (task === 'signUp' || task === 'updatePlan') {
return dom.maybe(this._model.signupPlan, _plan => this._buildPlanPaymentSummary(_plan, task));
} else if (task === 'signUpLite') {
return this.buildSubscriptionSummary();
} else if (task === 'addCard' || task === 'updateCard') {
return makeSummaryFeature('You are updating the default payment method');
} else if (task === 'updateAddress') {
return makeSummaryFeature('You are updating the company name and address');
} else if (task === 'updateDomain') {
return makeSummaryFeature('You are updating the company name and domain');
} else {
return null;
}
}
private _buildPlanPaymentSummary(plan: IBillingPlan, task: BillingTask) {
return dom.domComputed(this._model.subscription, sub => {
let stubSub: ISubscriptionModel|undefined;
if (sub && !sub.periodEnd) {
// Stripe subscriptions have a defined end.
// If the periodEnd is unknown, that means there is as yet no stripe subscription,
// and the user is signing up or renewing an expired subscription as opposed to upgrading.
stubSub = sub;
sub = undefined;
}
if (plan.amount === 0) {
// User is cancelling their subscription.
return [
makeSummaryFeature(['You are cancelling the subscription']),
sub ? makeSummaryFeature(['Your subscription will end on ', dateFmt(sub.periodEnd)]) : null
];
} else if (sub && sub.activePlan && plan.amount < sub.activePlan.amount) {
// User is downgrading their plan.
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
makeSummaryFeature(['Your plan will change on ', dateFmt(sub.periodEnd)]),
makeSummaryFeature('You will not be charged until the plan changes'),
makeSummaryFeature([`Your new ${plan.interval}ly subtotal is `,
getPriceString(plan.amount * sub.userCount)])
];
} else if (!sub) {
const planPriceStr = getPriceString(plan.amount);
const subtotal = plan.amount * (stubSub?.userCount || 1);
// This is a new subscription, either a fresh sign ups, or renewal after cancellation.
// The server will allow the trial period only for fresh sign ups.
const trialSummary = (plan.trial_period_days && task === 'signUp') ?
makeSummaryFeature([`The plan is free for `, `${plan.trial_period_days} days`]) : null;
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
dom.domComputed(this._showConfirmPage, confirmPage => {
const subTotalOptions = {coupon: confirmPage ? this._formData.coupon : undefined};
const subTotalPriceStr = getPriceString(subtotal, subTotalOptions);
const totalPriceStr = getPriceString(subtotal, {...subTotalOptions, taxRate: stubSub?.taxRate ?? 0});
if (confirmPage) {
return [
this._formData.coupon ?
makeSummaryFeature([
'You applied the ',
this._formData.coupon.name + ` (${getDiscountAmountString(this._formData.coupon)}) `,
]) : null,
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
// Note that on sign up, the number of users in the new org is always one.
trialSummary || makeSummaryFeature(['You will be charged ', totalPriceStr, ' to start'])
];
} else {
return [
// Note that on sign up, the number of users in the new org is always one.
makeSummaryFeature([`Your price is `, planPriceStr, ` per user per ${plan.interval}`]),
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
trialSummary
];
}
})
];
} else if (plan.amount > sub.activePlan.amount) {
const refund = sub.valueRemaining || 0;
const subtotal = plan.amount * sub.userCount;
const taxRate = sub.taxRate;
// User is upgrading their plan.
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
dom.domComputed(this._showConfirmPage, confirmPage => {
const subTotalOptions = {coupon: confirmPage ? this._formData.coupon : undefined};
const subTotalPriceStr = getPriceString(subtotal, subTotalOptions);
const startPriceStr = getPriceString(subtotal, {...subTotalOptions, taxRate, refund});
return [
confirmPage && this._formData.coupon ?
makeSummaryFeature([
'You applied the ',
this._formData.coupon.name + ` (${getDiscountAmountString(this._formData.coupon)}) `,
]) : null,
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
makeSummaryFeature(['You will be charged ', startPriceStr, ' to start']),
refund > 0 ? makeSummaryFeature(['Your charge is prorated based on the remaining plan time']) : null,
];
}),
];
} else {
// User is cancelling their decision to downgrade their plan.
return [
makeSummaryFeature(['You will remain subscribed to the ', plan.nickname, ' plan']),
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `,
getPriceString(plan.amount * sub.userCount)]),
makeSummaryFeature(['Your next payment will be on ', dateFmt(sub.periodEnd)])
];
}
});
}
private _buildPaymentBtns(submitText: string) {
const task = this._model.currentTask.get();
this._isSubmitting.set(false); // Reset status on build.
return css.paymentBtnRow(
bigBasicButton('Back',
dom.on('click', () => window.history.back()),
dom.show((use) => task !== 'signUp' || !use(this._showConfirmPage)),
dom.boolAttr('disabled', this._isSubmitting),
testId('back')
),
bigBasicButtonLink('Edit',
dom.show(this._showConfirmPage),
dom.on('click', () => this._showConfirmPage.set(false)),
dom.boolAttr('disabled', this._isSubmitting),
testId('edit')
),
bigPrimaryButton({style: 'margin-left: 10px;'},
dom.text((use) => (task !== 'signUp' || use(this._showConfirmPage)) ? submitText : 'Review'),
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 the form is built, fetch the form data.
if (this._form) {
this._formData = await this._form.getFormData();
}
// In general, submit data to the server. In the case of signup, get the tax rate
// and show confirmation data before submitting.
if (task !== 'signUp' || this._showConfirmPage.get()) {
await this._model.submitPaymentPage(this._formData);
// On submit, reset confirm page and form data.
this._showConfirmPage.set(false);
this._formData = {};
} else {
if (this._model.signupTaxRate === undefined) {
await this._model.fetchSignupTaxRate(this._formData);
}
this._showConfirmPage.set(true);
this._isSubmitting.set(false);
}
} catch (err) {
// Note that submitPaymentPage/fetchSignupTaxRate 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();
}
}
}
private _showRemoveCardModal(): void {
confirmModal(`Remove Payment Card`, 'Remove',
() => this._model.removeCard(),
`This is the only payment method associated with the account.\n\n` +
`If removed, another payment method will need to be added before the ` +
`next payment is due.`);
}
}
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
});
}
function getDiscountAmountString(coupon: IBillingCoupon): string {
if (coupon.amount_off !== null) {
return `${getPriceString(coupon.amount_off)} off`;
} else {
return `${coupon.percent_off!}% off`;
}
}
/**
* 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 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"; }
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'});
}
function formatCityStateZip(address: Partial<IBillingAddress>) {
const cityState = [address.city, address.state].filter(Boolean).join(', ');
return [cityState, address.postal_code].filter(Boolean).join(' ');
}