From dca3abec1d65c36e3508a83658867f8a39e23c83 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Thu, 24 Jun 2021 19:32:37 -0400 Subject: [PATCH] (core) complete light sign-up flow for appsumo, and customize summaries Summary: Current appsumo sign-up flow doesn't reach the billing pages. This diff nudges user on through that extra step. It also tweaks plan summaries to say what special appsumo features are in effect (member count prepaid for). Test Plan: manual Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2882 --- app/client/ui/BillingPage.ts | 201 +++++++++++++++++++++-------------- app/common/orgNameUtils.ts | 2 + app/server/lib/FlexServer.ts | 18 ++++ 3 files changed, 139 insertions(+), 82 deletions(-) diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index 9c7aa3f1..4c1b2072 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -129,92 +129,127 @@ export class BillingPage extends Disposable { ), css.summaryBlock( css.billingHeader('Billing Summary'), - 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; - return [ - css.summaryFeatures( - validPlan ? [ - makeSummaryFeature(['You are subscribed to the ', sub.activePlan.nickname, ' 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' : ''}`]), - makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `, - getPriceString(moneyPlan.amount * sub.userCount)]), - sub.discountName ? makeSummaryFeature([`You receive the `, sub.discountName]) : 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, - 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 - ]; - }) + 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 tier = sub.discountName && sub.discountName.includes(' Tier '); + const planName = tier ? sub.discountName! : sub.activePlan.nickname; + 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(sub.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, + (sub.discountName && !tier) ? makeSummaryFeature([`You receive the `, sub.discountName]) : 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, + 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; + } + public buildPlansPage() { // Fetch plan and card data if not already present. this._model.fetchData().catch(reportError); @@ -561,6 +596,8 @@ export class BillingPage extends Disposable { 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') { diff --git a/app/common/orgNameUtils.ts b/app/common/orgNameUtils.ts index 956c9fef..48a588b3 100644 --- a/app/common/orgNameUtils.ts +++ b/app/common/orgNameUtils.ts @@ -41,6 +41,8 @@ export function checkSubdomainValidity(subdomain: string): void { } // 'docs-*' is reserved for personal orgs. if (subdomain.startsWith('docs-')) { throw new Error('Domain cannot use reserved prefix "docs-".'); } + // 'o-*' is reserved for automatic org domains. + if (subdomain.startsWith('o-')) { throw new Error('Domain cannot use reserved prefix "o-".'); } // 'doc-worker-*' is reserved for doc workers. if (subdomain.startsWith('doc-worker-')) { throw new Error('Domain cannot use reserved prefix "doc-worker-".'); } // special subdomains like _domainkey. diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 932c5ac1..622bd8a9 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -668,6 +668,24 @@ export class FlexServer implements GristServer { const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; return res.redirect(`${prefix}/welcome/user`); } + if (mreq.org && mreq.org.startsWith('o-')) { + // We are on a team site without a custom subdomain. + // If the user is a billing manager for the org, and the org + // is supposed to have a custom subdomain, forward the user + // to a page to set it. + + // TODO: this is more or less a hack for AppSumo signup flow, + // and could be removed if/when signup flow is revamped. + + // If "welcomeNewUser" is ever added to billing pages, we'd need + // to avoid a redirect loop. + + const orgInfo = this.dbManager.unwrapQueryResult(await this.dbManager.getOrg({userId: user.id}, mreq.org)); + if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.product.features.vanityDomain) { + const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : ''; + return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`); + } + } next(); });