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, getMainOrgUrl, 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 = Observable.create(this, false); // Indicates that the payment page submit button has been clicked to prevent repeat requests. private readonly _isSubmitting: Observable = 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(getMainOrgUrl())}, 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|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 { 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) { const cityState = [address.city, address.state].filter(Boolean).join(', '); return [cityState, address.postal_code].filter(Boolean).join(' '); }