mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Product update popups and hosted stripe integration
Summary: - Showing nudge to individual users to sign up for free team plan. - Implementing billing page to upgrade from free team to pro. - New modal with upgrade options and free team site signup. - Integrating Stripe-hosted UI for checkout and plan management. Test Plan: updated tests Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3456
This commit is contained in:
121
app/client/lib/Validator.ts
Normal file
121
app/client/lib/Validator.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Disposable, dom, Observable, styled } from 'grainjs';
|
||||
import { colors } from 'app/client/ui2018/cssVars';
|
||||
|
||||
/**
|
||||
* Simple validation controls. Renders as a red text with a validation message.
|
||||
*
|
||||
* Sample usage:
|
||||
*
|
||||
* const group = new ValidationGroup();
|
||||
* async function save() {
|
||||
* if (await group.validate()) {
|
||||
* api.save(....)
|
||||
* }
|
||||
* }
|
||||
* ....
|
||||
* dom('div',
|
||||
* dom('Login', 'Enter login', input(login), group.resetInput()),
|
||||
* dom.create(Validator, accountGroup, 'Login is required', () => Boolean(login.get()) === true)),
|
||||
* dom.create(Validator, accountGroup, 'Login must by unique', async () => await throwsIfLoginIsTaken(login.get())),
|
||||
* dom('button', dom.on('click', save))
|
||||
* )
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validation function. Can return either boolean value or throw an error with a message that will be displayed
|
||||
* in a validator instance.
|
||||
*/
|
||||
type ValidationFunction = () => (boolean | Promise<boolean> | void | Promise<void>)
|
||||
|
||||
/**
|
||||
* Validation groups allow you to organize validator controls on a page as a set.
|
||||
* Each validation group can perform validation independently from other validation groups on the page.
|
||||
*/
|
||||
export class ValidationGroup {
|
||||
// List of attached validators.
|
||||
private _validators: Validator[] = [];
|
||||
/**
|
||||
* Runs all validators check functions. Returns result of the validation.
|
||||
*/
|
||||
public async validate() {
|
||||
let valid = true;
|
||||
for (const val of this._validators) {
|
||||
try {
|
||||
const result = await val.check();
|
||||
// Validator can either return boolean, Promise<boolean> or void. Booleans are straightforwards.
|
||||
// When validator has a void/Promise<void> result it means that it just asserts certain invariant, and should
|
||||
// throw an exception when this invariant is not met. Error message can be used to amend the message in the
|
||||
// validator instance.
|
||||
const isValid = typeof result === 'boolean' ? result : true;
|
||||
val.set(isValid);
|
||||
if (!isValid) { valid = false; break; }
|
||||
} catch (err) {
|
||||
valid = false;
|
||||
val.set((err as Error).message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
}
|
||||
/**
|
||||
* Attaches single validator instance to this group. Validator can be in multiple groups
|
||||
* at the same time.
|
||||
*/
|
||||
public add(validator: Validator) {
|
||||
this._validators.push(validator);
|
||||
}
|
||||
/**
|
||||
* Helper that can be attached to the input element to reset validation status.
|
||||
*/
|
||||
public inputReset() {
|
||||
return dom.on('input', this.reset.bind(this));
|
||||
}
|
||||
/**
|
||||
* Reset all validators statuses.
|
||||
*/
|
||||
public reset() {
|
||||
this._validators.forEach(val => val.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator instance. When triggered shows a red text with an error message.
|
||||
*/
|
||||
export class Validator extends Disposable {
|
||||
private _isValid = Observable.create(this, true);
|
||||
private _message = Observable.create(this, '');
|
||||
constructor(public group: ValidationGroup, message: string, public check: ValidationFunction) {
|
||||
super();
|
||||
group.add(this);
|
||||
this._message.set(message);
|
||||
}
|
||||
/**
|
||||
* Helper that can be attached to the input element to reset validation status.
|
||||
*/
|
||||
public inputReset() {
|
||||
return dom.on('input', this.set.bind(this, true));
|
||||
}
|
||||
/**
|
||||
* Sets the validation status. If isValid is a string it is treated as a falsy value, and will
|
||||
* mark this validator as invalid.
|
||||
*/
|
||||
public set(isValid: boolean | string) {
|
||||
if (this.isDisposed()) { return; }
|
||||
if (typeof isValid === 'string') {
|
||||
this._message.set(isValid);
|
||||
this._isValid.set(!isValid);
|
||||
} else {
|
||||
this._isValid.set(isValid ? true : false);
|
||||
}
|
||||
}
|
||||
public buildDom() {
|
||||
return cssError(
|
||||
dom.text(this._message),
|
||||
dom.hide(this._isValid),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssError = styled('div.validator', `
|
||||
color: ${colors.error};
|
||||
`);
|
||||
@@ -13,9 +13,10 @@ import {UserPrefs} from 'app/common/Prefs';
|
||||
import {isOwner} from 'app/common/roles';
|
||||
import {getTagManagerScript} from 'app/common/tagManager';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {getOrgName, Organization, OrgError, SUPPORT_EMAIL, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {getUserPrefObs, getUserPrefsObs} from 'app/client/models/UserPrefs';
|
||||
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
|
||||
import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrades';
|
||||
|
||||
export {reportError} from 'app/client/models/errors';
|
||||
|
||||
@@ -73,8 +74,13 @@ export interface AppModel {
|
||||
pageType: Observable<PageType>;
|
||||
|
||||
notifier: Notifier;
|
||||
planName: string|null;
|
||||
|
||||
refreshOrgUsage(): Promise<void>;
|
||||
showUpgradeModal(): void;
|
||||
showNewSiteModal(): void;
|
||||
isBillingManager(): boolean; // If user is a billing manager for this org
|
||||
isSupport(): boolean; // If user is a Support user
|
||||
}
|
||||
|
||||
export class TopAppModelImpl extends Disposable implements TopAppModel {
|
||||
@@ -213,6 +219,30 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
this._recordSignUpIfIsNewUser();
|
||||
}
|
||||
|
||||
public get planName() {
|
||||
return this.currentOrg?.billingAccount?.product.name ?? null;
|
||||
}
|
||||
|
||||
public async showUpgradeModal() {
|
||||
if (this.planName && this.currentOrg) {
|
||||
buildUpgradeModal(this, this.planName);
|
||||
}
|
||||
}
|
||||
|
||||
public async showNewSiteModal() {
|
||||
if (this.planName) {
|
||||
buildNewSiteModal(this, this.planName);
|
||||
}
|
||||
}
|
||||
|
||||
public isSupport() {
|
||||
return this.currentValidUser?.email === SUPPORT_EMAIL;
|
||||
}
|
||||
|
||||
public isBillingManager() {
|
||||
return Boolean(this.currentOrg?.billingAccount?.isManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and update the current org's usage.
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {IFormData} from 'app/client/ui/BillingForm';
|
||||
import {BillingAPI, BillingAPIImpl, BillingSubPage, BillingTask, IBillingCoupon} from 'app/common/BillingAPI';
|
||||
import {IBillingCard, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI';
|
||||
import {BillingAPI, BillingAPIImpl, BillingSubPage,
|
||||
BillingTask, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import {bundleChanges, Computed, Disposable, Observable} from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import omit = require('lodash/omit');
|
||||
|
||||
export interface BillingModel {
|
||||
readonly error: Observable<string|null>;
|
||||
@@ -15,8 +13,6 @@ export interface BillingModel {
|
||||
// Client-friendly version of the IBillingSubscription fetched from the server.
|
||||
// See ISubscriptionModel for details.
|
||||
readonly subscription: Observable<ISubscriptionModel|undefined>;
|
||||
// Payment card fetched from the server.
|
||||
readonly card: Observable<IBillingCard|null>;
|
||||
|
||||
readonly currentSubpage: Computed<BillingSubPage|undefined>;
|
||||
// The billingTask query param of the url - indicates the current operation, if any.
|
||||
@@ -29,8 +25,6 @@ export interface BillingModel {
|
||||
// Indicates whether the request for billing account information fails with unauthorized.
|
||||
// Initialized to false until the request is made.
|
||||
readonly isUnauthorized: Observable<boolean>;
|
||||
// The tax rate to use for the sign up charge. Initialized by calling fetchSignupTaxRate.
|
||||
signupTaxRate: number|undefined;
|
||||
|
||||
reportBlockingError(this: void, err: Error): void;
|
||||
|
||||
@@ -40,25 +34,25 @@ export interface BillingModel {
|
||||
addManager(email: string): Promise<void>;
|
||||
// Remove billing account manager.
|
||||
removeManager(email: string): Promise<void>;
|
||||
// Remove the payment card from the account.
|
||||
removeCard(): Promise<void>;
|
||||
// Returns a boolean indicating if the org domain string is available.
|
||||
isDomainAvailable(domain: string): Promise<boolean>;
|
||||
// Triggered when submit is clicked on the payment page. Performs the API billing account
|
||||
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
|
||||
submitPaymentPage(formData?: IFormData): Promise<void>;
|
||||
// Fetches coupon information for a valid `discountCode`.
|
||||
fetchSignupCoupon(discountCode: string): Promise<IBillingCoupon>;
|
||||
// Fetches the effective tax rate for the address in the given form.
|
||||
fetchSignupTaxRate(formData: IFormData): Promise<void>;
|
||||
// Fetches subscription data associated with the given org, if the pages are associated with an
|
||||
// org and the user is a plan manager. Otherwise, fetches available plans only.
|
||||
fetchData(forceReload?: boolean): Promise<void>;
|
||||
// Triggered when submit is clicked on the payment page. Performs the API billing account
|
||||
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
|
||||
submitPaymentPage(formData?: IFormData): Promise<void>;
|
||||
// Cancels current subscription.
|
||||
cancelCurrentPlan(): Promise<void>;
|
||||
// Retrieves customer portal session URL.
|
||||
getCustomerPortalUrl(): string;
|
||||
// Renews plan (either by opening customer portal or creating Stripe Checkout session)
|
||||
renewPlan(): string;
|
||||
}
|
||||
|
||||
export interface ISubscriptionModel extends Omit<IBillingSubscription, 'plans'|'card'> {
|
||||
export interface ISubscriptionModel extends IBillingSubscription {
|
||||
// The active plan.
|
||||
activePlan: IBillingPlan;
|
||||
activePlan: IBillingPlan|null;
|
||||
// The upcoming plan, or null if the current plan is not set to end.
|
||||
upcomingPlan: IBillingPlan|null;
|
||||
}
|
||||
@@ -73,8 +67,6 @@ export class BillingModelImpl extends Disposable implements BillingModel {
|
||||
// Client-friendly version of the IBillingSubscription fetched from the server.
|
||||
// See ISubscriptionModel for details.
|
||||
public readonly subscription: Observable<ISubscriptionModel|undefined> = Observable.create(this, undefined);
|
||||
// Payment card fetched from the server.
|
||||
public readonly card: Observable<IBillingCard|null> = Observable.create(this, null);
|
||||
|
||||
public readonly currentSubpage: Computed<BillingSubPage|undefined> =
|
||||
Computed.create(this, urlState().state, (use, s) => s.billing === 'billing' ? undefined : s.billing);
|
||||
@@ -88,8 +80,7 @@ export class BillingModelImpl extends Disposable implements BillingModel {
|
||||
// The plan to which the user is in process of signing up.
|
||||
public readonly signupPlan: Computed<IBillingPlan|undefined> =
|
||||
Computed.create(this, this.plans, this.signupPlanId, (use, plans, pid) => plans.find(_p => _p.id === pid));
|
||||
// The tax rate to use for the sign up charge. Initialized by calling fetchSignupTaxRate.
|
||||
public signupTaxRate: number|undefined;
|
||||
|
||||
|
||||
// Indicates whether the request for billing account information fails with unauthorized.
|
||||
// Initialized to false until the request is made.
|
||||
@@ -121,107 +112,51 @@ export class BillingModelImpl extends Disposable implements BillingModel {
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the payment card from the account.
|
||||
public async removeCard(): Promise<void> {
|
||||
try {
|
||||
await this._billingAPI.removeCard();
|
||||
this.card.set(null);
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
}
|
||||
|
||||
public isDomainAvailable(domain: string): Promise<boolean> {
|
||||
return this._billingAPI.isDomainAvailable(domain);
|
||||
}
|
||||
|
||||
public async fetchSignupCoupon(discountCode: string): Promise<IBillingCoupon> {
|
||||
return await this._billingAPI.getCoupon(discountCode);
|
||||
public getCustomerPortalUrl() {
|
||||
return this._billingAPI.customerPortal();
|
||||
}
|
||||
|
||||
public renewPlan() {
|
||||
return this._billingAPI.renewPlan();
|
||||
}
|
||||
|
||||
public async cancelCurrentPlan() {
|
||||
const data = await this._billingAPI.cancelCurrentPlan();
|
||||
return data;
|
||||
}
|
||||
|
||||
public async submitPaymentPage(formData: IFormData = {}): Promise<void> {
|
||||
const task = this.currentTask.get();
|
||||
const planId = this.signupPlanId.get();
|
||||
// TODO: The server should prevent most of the errors in this function from occurring by
|
||||
// redirecting improper urls.
|
||||
try {
|
||||
if (task === 'signUp') {
|
||||
// Sign up from an unpaid plan to a paid plan.
|
||||
if (!planId) { throw new Error('BillingPage _submit error: no plan selected'); }
|
||||
if (!formData.token) { throw new Error('BillingPage _submit error: no card submitted'); }
|
||||
if (!formData.address) { throw new Error('BillingPage _submit error: no address submitted'); }
|
||||
if (!formData.settings) { throw new Error('BillingPage _submit error: no settings submitted'); }
|
||||
const {token, address, settings, coupon} = formData;
|
||||
const o = await this._billingAPI.signUp(planId, token, address, settings, coupon?.promotion_code);
|
||||
if (o && o.domain) {
|
||||
await urlState().pushUrl({ org: o.domain, billing: 'billing', params: undefined });
|
||||
} else {
|
||||
// TODO: show problems nicely
|
||||
throw new Error('BillingPage _submit error: problem creating new organization');
|
||||
if (task === 'signUpLite' || task === 'updateDomain') {
|
||||
// All that can change here is company name, and domain.
|
||||
const org = this._appModel.currentOrg;
|
||||
const name = formData.settings && formData.settings.name;
|
||||
const domain = formData.settings && formData.settings.domain;
|
||||
const newDomain = domain !== org?.domain;
|
||||
const newSettings = org && (name !== org.name || newDomain) && formData.settings;
|
||||
// If the address or settings have a new value, run the update.
|
||||
if (newSettings) {
|
||||
await this._billingAPI.updateSettings(newSettings || undefined);
|
||||
}
|
||||
// If the domain has changed, should redirect page.
|
||||
if (newDomain) {
|
||||
window.location.assign(urlState().makeUrl({ org: domain, billing: 'billing', params: undefined }));
|
||||
return;
|
||||
}
|
||||
// If there is an org update, re-initialize the org in the client.
|
||||
if (newSettings) { this._appModel.topAppModel.initialize(); }
|
||||
} else {
|
||||
// Any task after sign up.
|
||||
if (task === 'updatePlan') {
|
||||
// Change plan from a paid plan to another paid plan or to the free plan.
|
||||
if (!planId) { throw new Error('BillingPage _submit error: no plan selected'); }
|
||||
await this._billingAPI.setSubscription(planId, {tokenId: formData.token});
|
||||
} else if (task === 'addCard' || task === 'updateCard') {
|
||||
// Add or update payment card.
|
||||
if (!formData.token) { throw new Error('BillingPage _submit error: missing card info token'); }
|
||||
await this._billingAPI.setCard(formData.token);
|
||||
} else if (task === 'updateAddress') {
|
||||
const org = this._appModel.currentOrg;
|
||||
const sub = this.subscription.get();
|
||||
const name = formData.settings && formData.settings.name;
|
||||
// Get the values of the new address and settings if they have changed.
|
||||
const newAddr = sub && !isEqual(formData.address, sub.address) && formData.address;
|
||||
const newSettings = org && (name !== org.name) && formData.settings;
|
||||
// If the address or settings have a new value, run the update.
|
||||
if (newAddr || newSettings) {
|
||||
await this._billingAPI.updateAddress(newAddr || undefined, newSettings || undefined);
|
||||
}
|
||||
// If there is an org update, re-initialize the org in the client.
|
||||
if (newSettings) { this._appModel.topAppModel.initialize(); }
|
||||
} else if (task === 'signUpLite' || task === 'updateDomain') {
|
||||
// All that can change here is company name, and domain.
|
||||
const org = this._appModel.currentOrg;
|
||||
const name = formData.settings && formData.settings.name;
|
||||
const domain = formData.settings && formData.settings.domain;
|
||||
const newDomain = domain !== org?.domain;
|
||||
const newSettings = org && (name !== org.name || newDomain) && formData.settings;
|
||||
// If the address or settings have a new value, run the update.
|
||||
if (newSettings) {
|
||||
await this._billingAPI.updateAddress(undefined, newSettings || undefined);
|
||||
}
|
||||
// If the domain has changed, should redirect page.
|
||||
if (newDomain) {
|
||||
window.location.assign(urlState().makeUrl({ org: domain, billing: 'billing', params: undefined }));
|
||||
return;
|
||||
}
|
||||
// If there is an org update, re-initialize the org in the client.
|
||||
if (newSettings) { this._appModel.topAppModel.initialize(); }
|
||||
} else {
|
||||
throw new Error('BillingPage _submit error: no task in progress');
|
||||
}
|
||||
// Show the billing summary page after submission
|
||||
await urlState().pushUrl({ billing: 'billing', params: undefined });
|
||||
throw new Error('BillingPage _submit error: no task in progress');
|
||||
}
|
||||
} catch (err) {
|
||||
// TODO: These errors may need to be reported differently since they're not user-friendly
|
||||
reportError(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchSignupTaxRate(formData: IFormData): Promise<void> {
|
||||
try {
|
||||
if (this.currentTask.get() !== 'signUp') {
|
||||
throw new Error('fetchSignupTaxRate only available during signup');
|
||||
}
|
||||
if (!formData.address) {
|
||||
throw new Error('Signup form data must include address');
|
||||
}
|
||||
this.signupTaxRate = await this._billingAPI.getTaxRate(formData.address);
|
||||
// Show the billing summary page after submission
|
||||
await urlState().pushUrl({ billing: 'billing', params: undefined });
|
||||
} catch (err) {
|
||||
// TODO: These errors may need to be reported differently since they're not user-friendly
|
||||
reportError(err);
|
||||
@@ -231,13 +166,8 @@ export class BillingModelImpl extends Disposable implements BillingModel {
|
||||
|
||||
// If forceReload is set, re-fetches and updates already fetched data.
|
||||
public async fetchData(forceReload: boolean = false): Promise<void> {
|
||||
if (this.currentSubpage.get() === 'plans' && !this._appModel.currentOrg) {
|
||||
// If these are billing sign up pages, fetch the plan options only.
|
||||
await this._fetchPlans();
|
||||
} else {
|
||||
// If these are billing settings pages for an existing org, fetch the subscription data.
|
||||
await this._fetchSubscription(forceReload);
|
||||
}
|
||||
// If these are billing settings pages for an existing org, fetch the subscription data.
|
||||
await this._fetchSubscription(forceReload);
|
||||
}
|
||||
|
||||
private _reportBlockingError(err: Error) {
|
||||
@@ -260,10 +190,9 @@ export class BillingModelImpl extends Disposable implements BillingModel {
|
||||
const subModel: ISubscriptionModel = {
|
||||
activePlan: sub.plans[sub.planIndex],
|
||||
upcomingPlan: sub.upcomingPlanIndex !== sub.planIndex ? sub.plans[sub.upcomingPlanIndex] : null,
|
||||
...omit(sub, 'plans', 'card'),
|
||||
...sub
|
||||
};
|
||||
this.subscription.set(subModel);
|
||||
this.card.set(sub.card);
|
||||
// Clear the fetch errors on success.
|
||||
this.isUnauthorized.set(false);
|
||||
this.error.set(null);
|
||||
@@ -274,11 +203,4 @@ export class BillingModelImpl extends Disposable implements BillingModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches the plans only - used when the billing pages are not associated with an org.
|
||||
private async _fetchPlans(): Promise<void> {
|
||||
if (this.plans.get().length === 0) {
|
||||
this.plans.set(await this._billingAPI.getPlans());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export class AccountWidget extends Disposable {
|
||||
menuItemLink(urlState().setLinkUrl({billing: 'billing'}), 'Billing Account') :
|
||||
menuItem(() => null, 'Billing Account', dom.cls('disabled', true))
|
||||
) :
|
||||
menuItemLink({href: commonUrls.plans}, 'Upgrade Plan'),
|
||||
menuItem(() => this._appModel.showUpgradeModal(), 'Upgrade Plan'),
|
||||
|
||||
mobileModeToggle,
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import * as version from 'app/common/version';
|
||||
import {BindableValue, Disposable, dom, styled} from "grainjs";
|
||||
import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
||||
import {isTemplatesOrg, Organization, SUPPORT_EMAIL} from 'app/common/UserAPI';
|
||||
import {isTemplatesOrg, Organization} from 'app/common/UserAPI';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||
@@ -35,10 +35,8 @@ export class AppHeader extends Disposable {
|
||||
public buildDom() {
|
||||
const theme = getTheme(this._appModel.topAppModel.productFlavor);
|
||||
|
||||
const user = this._appModel.currentValidUser;
|
||||
const currentOrg = this._appModel.currentOrg;
|
||||
const isBillingManager = Boolean(currentOrg && currentOrg.billingAccount &&
|
||||
(currentOrg.billingAccount.isManager || user?.email === SUPPORT_EMAIL));
|
||||
const isBillingManager = this._appModel.isBillingManager() || this._appModel.isSupport();
|
||||
|
||||
return cssAppHeader(
|
||||
cssAppHeader.cls('-widelogo', theme.wideLogo || false),
|
||||
|
||||
@@ -1,44 +1,21 @@
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {BillingModel} from 'app/client/models/BillingModel';
|
||||
import * as css from 'app/client/ui/BillingPageCss';
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {IOption, select} from 'app/client/ui2018/menus';
|
||||
import type {ApiError} from 'app/common/ApiError';
|
||||
import {IBillingAddress, IBillingCard, IBillingCoupon, IBillingOrgSettings,
|
||||
IFilledBillingAddress} from 'app/common/BillingAPI';
|
||||
import {IBillingOrgSettings} from 'app/common/BillingAPI';
|
||||
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Organization} from 'app/common/UserAPI';
|
||||
import {Computed, Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable, styled} from 'grainjs';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
import {Disposable, dom, DomArg, IDisposableOwnerT, makeTestId, Observable} from 'grainjs';
|
||||
|
||||
const G = getBrowserGlobals('Stripe', 'window');
|
||||
const testId = makeTestId('test-bp-');
|
||||
const states = [
|
||||
'AK', 'AL', 'AR', 'AS', 'AZ', 'CA', 'CO', 'CT', 'DC', 'DE', 'FL', 'FM', 'GA', 'GU', 'HI',
|
||||
'IA', 'ID', 'IL', 'IN', 'KS', 'KY', 'LA', 'MA', 'MD', 'ME', 'MH', 'MI', 'MN', 'MO', 'MP',
|
||||
'MS', 'MT', 'NC', 'ND', 'NE', 'NH', 'NJ', 'NM', 'NV', 'NY', 'OH', 'OK', 'OR', 'PA', 'PR',
|
||||
'PW', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VA', 'VI', 'VT', 'WA', 'WI', 'WV', 'WY'
|
||||
];
|
||||
|
||||
export interface IFormData {
|
||||
address?: IFilledBillingAddress;
|
||||
card?: IBillingCard;
|
||||
token?: string;
|
||||
settings?: IBillingOrgSettings;
|
||||
coupon?: IBillingCoupon;
|
||||
}
|
||||
|
||||
|
||||
// Optional autofill vales to pass in to the BillingForm constructor.
|
||||
interface IAutofill {
|
||||
address?: Partial<IBillingAddress>;
|
||||
settings?: Partial<IBillingOrgSettings>;
|
||||
// Note that the card name is the only value that may be initialized, since the other card
|
||||
// information is sensitive.
|
||||
card?: Partial<IBillingCard>;
|
||||
coupon?: Partial<IBillingCoupon>;
|
||||
}
|
||||
|
||||
// An object containing a function to check the validity of its observable value.
|
||||
@@ -51,69 +28,34 @@ interface IValidated<T> {
|
||||
}
|
||||
|
||||
export class BillingForm extends Disposable {
|
||||
private readonly _address: BillingAddressForm|null;
|
||||
private readonly _discount: BillingDiscountForm|null;
|
||||
private readonly _payment: BillingPaymentForm|null;
|
||||
private readonly _settings: BillingSettingsForm|null;
|
||||
|
||||
constructor(
|
||||
org: Organization|null,
|
||||
billingModel: BillingModel,
|
||||
options: {payment: boolean, address: boolean, settings: boolean, domain: boolean, discount: boolean},
|
||||
options: {settings: boolean, domain: boolean},
|
||||
autofill: IAutofill = {}
|
||||
) {
|
||||
super();
|
||||
|
||||
// Get the number of forms - if more than one is present subheaders should be visible.
|
||||
const count = [options.settings, options.address, options.payment]
|
||||
.reduce((acc, x) => acc + (x ? 1 : 0), 0);
|
||||
|
||||
// Org settings form.
|
||||
this._settings = options.settings ? new BillingSettingsForm(billingModel, org, {
|
||||
showHeader: count > 1,
|
||||
showHeader: true,
|
||||
showDomain: options.domain,
|
||||
autofill: autofill.settings
|
||||
}) : null;
|
||||
|
||||
// Discount form.
|
||||
this._discount = options.discount ? new BillingDiscountForm(billingModel, {
|
||||
autofill: autofill.coupon
|
||||
}) : null;
|
||||
|
||||
// Address form.
|
||||
this._address = options.address ? new BillingAddressForm({
|
||||
showHeader: count > 1,
|
||||
autofill: autofill.address
|
||||
}) : null;
|
||||
|
||||
// Payment form.
|
||||
this._payment = options.payment ? new BillingPaymentForm({
|
||||
showHeader: count > 1,
|
||||
autofill: autofill.card
|
||||
}) : null;
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return [
|
||||
this._settings ? this._settings.buildDom() : null,
|
||||
this._discount ? this._discount.buildDom() : null,
|
||||
this._address ? this._address.buildDom() : null,
|
||||
this._payment ? this._payment.buildDom() : null
|
||||
];
|
||||
}
|
||||
|
||||
// Note that this will throw if any values are invalid.
|
||||
public async getFormData(): Promise<IFormData> {
|
||||
const settings = this._settings ? await this._settings.getSettings() : undefined;
|
||||
const address = this._address ? await this._address.getAddress() : undefined;
|
||||
const cardInfo = this._payment ? await this._payment.getCardAndToken() : undefined;
|
||||
const coupon = this._discount ? await this._discount.getCoupon() : undefined;
|
||||
return {
|
||||
settings,
|
||||
address,
|
||||
coupon,
|
||||
token: cardInfo ? cardInfo.token : undefined,
|
||||
card: cardInfo ? cardInfo.card : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,233 +115,6 @@ abstract class BillingSubForm extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the payment card entry form using Stripe Elements.
|
||||
*/
|
||||
class BillingPaymentForm extends BillingSubForm {
|
||||
private readonly _stripe: any;
|
||||
private readonly _elements: any;
|
||||
|
||||
// Stripe Element fields. Set when the elements are mounted to the dom.
|
||||
private readonly _numberElement: Observable<any> = Observable.create(this, null);
|
||||
private readonly _expiryElement: Observable<any> = Observable.create(this, null);
|
||||
private readonly _cvcElement: Observable<any> = Observable.create(this, null);
|
||||
private readonly _name: IValidated<string> = createValidated(this, checkRequired('Name'));
|
||||
|
||||
constructor(private readonly _options: {
|
||||
showHeader: boolean;
|
||||
autofill?: Partial<IBillingCard>;
|
||||
}) {
|
||||
super();
|
||||
const autofill = this._options.autofill;
|
||||
const stripeAPIKey = G.window.gristConfig.stripeAPIKey;
|
||||
try {
|
||||
this._stripe = G.Stripe(stripeAPIKey);
|
||||
this._elements = this._stripe.elements();
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
}
|
||||
if (autofill) {
|
||||
this._name.value.set(autofill.name || '');
|
||||
}
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return this._stripe ? css.paymentBlock(
|
||||
this._options.showHeader ? css.paymentSubHeader('Payment Method') : null,
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel('Cardholder Name'),
|
||||
this.billingInput(this._name, testId('card-name')),
|
||||
)
|
||||
),
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel({for: 'number-element'}, 'Card Number'),
|
||||
css.stripeInput({id: 'number-element'}), // A Stripe Element will be inserted here.
|
||||
testId('card-number')
|
||||
)
|
||||
),
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel({for: 'expiry-element'}, 'Expiry Date'),
|
||||
css.stripeInput({id: 'expiry-element'}), // A Stripe Element will be inserted here.
|
||||
testId('card-expiry')
|
||||
),
|
||||
css.paymentSpacer(),
|
||||
css.paymentField(
|
||||
css.paymentLabel({for: 'cvc-element'}, 'CVC / CVV Code'),
|
||||
css.stripeInput({id: 'cvc-element'}), // A Stripe Element will be inserted here.
|
||||
testId('card-cvc')
|
||||
)
|
||||
),
|
||||
css.inputError(
|
||||
dom.text(this.formError),
|
||||
testId('payment-form-error')
|
||||
),
|
||||
() => { setTimeout(() => this._mountStripeUI(), 0); }
|
||||
) : null;
|
||||
}
|
||||
|
||||
public async getCardAndToken(): Promise<{card: IBillingCard, token: string}> {
|
||||
// Note that we call createToken using only the card number element as the first argument
|
||||
// in accordance with the Stripe API:
|
||||
//
|
||||
// "If applicable, the Element pulls data from other Elements you’ve created on the same
|
||||
// instance of elements to tokenize—you only need to supply one element as the parameter."
|
||||
//
|
||||
// Source: https://stripe.com/docs/stripe-js/reference#stripe-create-token
|
||||
try {
|
||||
const result = await this._stripe.createToken(this._numberElement.get(), {name: await this._name.get()});
|
||||
if (result.error) { throw new Error(result.error.message); }
|
||||
return {
|
||||
card: result.token.card,
|
||||
token: result.token.id
|
||||
};
|
||||
} catch (e) {
|
||||
this.formError.set(e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private _mountStripeUI() {
|
||||
// Mount Stripe Element fields.
|
||||
this._mountStripeElement(this._numberElement, 'cardNumber', 'number-element');
|
||||
this._mountStripeElement(this._expiryElement, 'cardExpiry', 'expiry-element');
|
||||
this._mountStripeElement(this._cvcElement, 'cardCvc', 'cvc-element');
|
||||
}
|
||||
|
||||
private _mountStripeElement(elemObs: Observable<any>, stripeName: string, elementId: string): void {
|
||||
// For details on applying custom styles to Stripe Elements, see:
|
||||
// https://stripe.com/docs/stripe-js/reference#element-options
|
||||
const classes = {base: css.stripeInput.className};
|
||||
const style = {
|
||||
base: {
|
||||
'::placeholder': {
|
||||
color: colors.slate.value
|
||||
},
|
||||
'fontSize': vars.mediumFontSize.value,
|
||||
'fontFamily': vars.fontFamily.value
|
||||
}
|
||||
};
|
||||
if (!elemObs.get()) {
|
||||
const stripeInst = this._elements.create(stripeName, {classes, style});
|
||||
stripeInst.addEventListener('change', (event: any) => {
|
||||
if (event.error) { this.formError.set(event.error.message); }
|
||||
});
|
||||
elemObs.set(stripeInst);
|
||||
}
|
||||
elemObs.get().mount(`#${elementId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the company address entry form. Used by BillingPaymentForm when billing address is needed.
|
||||
*/
|
||||
class BillingAddressForm extends BillingSubForm {
|
||||
private readonly _address1: IValidated<string> = createValidated(this, checkRequired('Address'));
|
||||
private readonly _address2: IValidated<string> = createValidated(this, () => undefined);
|
||||
private readonly _city: IValidated<string> = createValidated(this, checkRequired('City'));
|
||||
private readonly _state: IValidated<string> = createValidated(this, checkFunc(
|
||||
(val) => !this._isUS.get() || Boolean(val), `State is required.`));
|
||||
private readonly _postal: IValidated<string> = createValidated(this, checkFunc(
|
||||
(val) => !this._isUS.get() || Boolean(val), 'Zip code is required.'));
|
||||
private readonly _countryCode: IValidated<string> = createValidated(this, checkRequired('Country'));
|
||||
|
||||
private _isUS = Computed.create(this, this._countryCode.value, (use, code) => (code === 'US'));
|
||||
|
||||
private readonly _countries: Array<IOption<string>> = getCountries();
|
||||
|
||||
constructor(private readonly _options: {
|
||||
showHeader: boolean;
|
||||
autofill?: Partial<IBillingAddress>;
|
||||
}) {
|
||||
super();
|
||||
const autofill = this._options.autofill;
|
||||
if (autofill) {
|
||||
this._address1.value.set(autofill.line1 || '');
|
||||
this._address2.value.set(autofill.line2 || '');
|
||||
this._city.value.set(autofill.city || '');
|
||||
this._state.value.set(autofill.state || '');
|
||||
this._postal.value.set(autofill.postal_code || '');
|
||||
}
|
||||
this._countryCode.value.set(autofill?.country || 'US');
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return css.paymentBlock(
|
||||
this._options.showHeader ? css.paymentSubHeader('Company Address') : null,
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel('Street Address'),
|
||||
this.billingInput(this._address1, testId('address-street'))
|
||||
)
|
||||
),
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel('Suite / Unit'),
|
||||
this.billingInput(this._address2, testId('address-suite'))
|
||||
)
|
||||
),
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel('City'),
|
||||
this.billingInput(this._city, testId('address-city'))
|
||||
),
|
||||
css.paymentSpacer(),
|
||||
css.paymentField({style: 'flex: 0.5 1 0;'},
|
||||
dom.domComputed(this._isUS, (isUs) =>
|
||||
isUs ? [
|
||||
css.paymentLabel('State'),
|
||||
cssSelect(this._state.value, states),
|
||||
] : [
|
||||
css.paymentLabel('State / Region'),
|
||||
this.billingInput(this._state),
|
||||
]
|
||||
),
|
||||
testId('address-state')
|
||||
)
|
||||
),
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel(dom.text((use) => use(this._isUS) ? 'Zip Code' : 'Postal Code')),
|
||||
this.billingInput(this._postal, testId('address-zip'))
|
||||
)
|
||||
),
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel('Country'),
|
||||
cssSelect(this._countryCode.value, this._countries),
|
||||
testId('address-country')
|
||||
)
|
||||
),
|
||||
css.inputError(
|
||||
dom.text(this.formError),
|
||||
testId('address-form-error')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Throws if any value is invalid. Returns a customer address as accepted by the customer
|
||||
// object in stripe.
|
||||
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
|
||||
public async getAddress(): Promise<IFilledBillingAddress|undefined> {
|
||||
try {
|
||||
return {
|
||||
line1: await this._address1.get(),
|
||||
line2: await this._address2.get(),
|
||||
city: await this._city.get(),
|
||||
state: await this._state.get(),
|
||||
postal_code: await this._postal.get(),
|
||||
country: await this._countryCode.get(),
|
||||
};
|
||||
} catch (e) {
|
||||
this.formError.set(e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the billing settings form, including the org name and the org subdomain values.
|
||||
*/
|
||||
@@ -430,7 +145,7 @@ class BillingSettingsForm extends BillingSubForm {
|
||||
const noEditAccess = Boolean(this._org && !roles.canEdit(this._org.access));
|
||||
const initDomain = this._options.autofill?.domain;
|
||||
return css.paymentBlock(
|
||||
this._options.showHeader ? css.paymentSubHeader('Team Site') : null,
|
||||
this._options.showHeader ? css.paymentLabel('Team name') : null,
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
this.billingInput(this._name,
|
||||
@@ -444,7 +159,7 @@ class BillingSettingsForm extends BillingSubForm {
|
||||
),
|
||||
this._options.showDomain ? css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel('URL'),
|
||||
css.paymentLabel('Team subdomain'),
|
||||
this.billingInput(this._domain,
|
||||
dom.boolAttr('disabled', () => noEditAccess),
|
||||
testId('settings-domain')
|
||||
@@ -490,67 +205,6 @@ class BillingSettingsForm extends BillingSubForm {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the billing discount form.
|
||||
*/
|
||||
class BillingDiscountForm extends BillingSubForm {
|
||||
private _isExpanded = Observable.create(this, false);
|
||||
private readonly _discountCode: IValidated<string> = createValidated(this, () => undefined);
|
||||
|
||||
constructor(
|
||||
private readonly _billingModel: BillingModel,
|
||||
private readonly _options: { autofill?: Partial<IBillingCoupon>; }
|
||||
) {
|
||||
super();
|
||||
if (this._options.autofill) {
|
||||
const { promotion_code } = this._options.autofill;
|
||||
this._discountCode.value.set(promotion_code ?? '');
|
||||
this._isExpanded.set(Boolean(promotion_code));
|
||||
}
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return dom.domComputed(this._isExpanded, isExpanded => [
|
||||
!isExpanded ?
|
||||
css.paymentBlock(
|
||||
css.paymentRow(
|
||||
css.billingText('Have a discount code?', testId('discount-code-question')),
|
||||
css.billingTextBtn(
|
||||
css.billingIcon('Settings'),
|
||||
'Apply',
|
||||
dom.on('click', () => { this.shouldAutoFocus = true; this._isExpanded.set(true); }),
|
||||
testId('apply-discount-code')
|
||||
)
|
||||
)
|
||||
) :
|
||||
css.paymentBlock(
|
||||
css.paymentRow(
|
||||
css.paymentField(
|
||||
css.paymentLabel('Discount Code'),
|
||||
this.billingInput(this._discountCode, testId('discount-code'), this.maybeAutoFocus()),
|
||||
)
|
||||
),
|
||||
css.inputError(
|
||||
dom.text(this.formError),
|
||||
testId('discount-form-error')
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
public async getCoupon() {
|
||||
const discountCode = await this._discountCode.get();
|
||||
if (discountCode.trim() === '') { return undefined; }
|
||||
|
||||
try {
|
||||
return await this._billingModel.fetchSignupCoupon(discountCode);
|
||||
} catch (e) {
|
||||
const message = (e as ApiError).details?.userError;
|
||||
this.formError.set(message || 'Invalid or expired discount code.');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkFunc(func: (val: string) => boolean, message: string) {
|
||||
return (val: string) => {
|
||||
@@ -588,23 +242,3 @@ function createValidated(
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCountries(): Array<IOption<string>> {
|
||||
// Require just the one file because it has all the data we need and is substantially smaller
|
||||
// than requiring the whole module.
|
||||
const countryNames = require("i18n-iso-countries/langs/en.json").countries;
|
||||
const codes = Object.keys(countryNames);
|
||||
const entries = codes.map(code => {
|
||||
// The module provides names that are either a string or an array of names. If an array, pick
|
||||
// the first one.
|
||||
const names = countryNames[code];
|
||||
return {value: code, label: Array.isArray(names) ? names[0] : names};
|
||||
});
|
||||
return sortBy(entries, 'label');
|
||||
}
|
||||
|
||||
const cssSelect = styled(select, `
|
||||
height: 42px;
|
||||
padding-left: 13px;
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,13 @@ import {getTimeFromNow, HomeModel, makeLocalViewSettings, ViewSettings} from 'ap
|
||||
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
import * as css from 'app/client/ui/DocMenuCss';
|
||||
import {buildHomeIntro} from 'app/client/ui/HomeIntro';
|
||||
import {buildUpgradeNudge} from 'app/client/ui/ProductUpgrades';
|
||||
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {showWelcomeQuestions} from 'app/client/ui/WelcomeQuestions';
|
||||
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
import {colors, isNarrowScreenObs} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
||||
@@ -24,11 +25,12 @@ import {IHomePage} from 'app/common/gristUrls';
|
||||
import {SortPref, ViewPref} from 'app/common/Prefs';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Document, Workspace} from 'app/common/UserAPI';
|
||||
import {Computed, computed, dom, DomContents, makeTestId, Observable, observable} from 'grainjs';
|
||||
import {Computed, computed, dom, DomContents, makeTestId, Observable, observable, styled} from 'grainjs';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
|
||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
||||
import {getUserOrgPrefObs, getUserOrgPrefsObs} from 'app/client/models/UserPrefs';
|
||||
|
||||
const testId = makeTestId('test-dm-');
|
||||
|
||||
@@ -46,94 +48,113 @@ export function createDocMenu(home: HomeModel) {
|
||||
));
|
||||
}
|
||||
|
||||
function createUpgradeNudge(home: HomeModel) {
|
||||
const isLoggedIn = !!home.app.currentValidUser;
|
||||
const isOnFreePersonal = home.app.currentOrg?.billingAccount?.product?.name === 'starter';
|
||||
const userOrgPrefs = getUserOrgPrefsObs(home.app);
|
||||
const seenNudge = getUserOrgPrefObs(userOrgPrefs, 'seenFreeTeamUpgradeNudge');
|
||||
return dom.maybe(use => isLoggedIn && isOnFreePersonal && !use(seenNudge),
|
||||
() => buildUpgradeNudge({
|
||||
onClose: () => seenNudge.set(true),
|
||||
// On show prices, we will clear the nudge in database once there is some free team site created
|
||||
// The better way is to read all workspaces that this person have and decide then - but this is done
|
||||
// asynchronously - so we potentially can show this nudge to people that already have team site.
|
||||
onUpgrade: () => home.app.showUpgradeModal()
|
||||
}));
|
||||
}
|
||||
|
||||
function createLoadedDocMenu(home: HomeModel) {
|
||||
const flashDocId = observable<string|null>(null);
|
||||
return css.docList(
|
||||
showWelcomeQuestions(home.app.userPrefsObs),
|
||||
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
||||
css.docListHeader('This service is not available right now'),
|
||||
dom('span', '(The organization needs a paid plan)')
|
||||
]),
|
||||
cssDocMenu(
|
||||
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
||||
css.docListHeader('This service is not available right now'),
|
||||
dom('span', '(The organization needs a paid plan)')
|
||||
]),
|
||||
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||
([page, workspace, showIntro]) => {
|
||||
const viewSettings: ViewSettings =
|
||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||
home;
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||
([page, workspace, showIntro]) => {
|
||||
const viewSettings: ViewSettings =
|
||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||
home;
|
||||
|
||||
return [
|
||||
// Hide the sort option only when showing intro.
|
||||
((showIntro && page === 'all') ? null :
|
||||
buildPrefs(viewSettings, {hideSort: showIntro})
|
||||
),
|
||||
|
||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or
|
||||
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
||||
css.docListHeader(css.docHeaderIconDark('PinBig'), 'Pinned Documents'),
|
||||
createPinnedDocs(home, home.currentWSPinnedDocs),
|
||||
]),
|
||||
|
||||
// Build the featured templates dom if on the Examples & Templates page.
|
||||
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
|
||||
css.featuredTemplatesHeader(
|
||||
css.featuredTemplatesIcon('Idea'),
|
||||
'Featured',
|
||||
testId('featured-templates-header')
|
||||
return [
|
||||
// Hide the sort option only when showing intro.
|
||||
((showIntro && page === 'all') ? null :
|
||||
buildPrefs(viewSettings, {hideSort: showIntro})
|
||||
),
|
||||
createPinnedDocs(home, home.featuredTemplates, true),
|
||||
]),
|
||||
|
||||
dom.maybe(home.available, () => [
|
||||
buildOtherSites(home),
|
||||
(showIntro && page === 'all' ?
|
||||
null :
|
||||
css.docListHeader(
|
||||
(
|
||||
page === 'all' ? 'All Documents' :
|
||||
page === 'templates' ?
|
||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||
hasFeaturedTemplates ? 'More Examples & Templates' : 'Examples & Templates'
|
||||
) :
|
||||
page === 'trash' ? 'Trash' :
|
||||
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
|
||||
),
|
||||
testId('doc-header'),
|
||||
)
|
||||
),
|
||||
(
|
||||
(page === 'all') ?
|
||||
dom('div',
|
||||
showIntro ? buildHomeIntro(home) : null,
|
||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||
) :
|
||||
(page === 'trash') ?
|
||||
dom('div',
|
||||
css.docBlock('Documents stay in Trash for 30 days, after which they get deleted permanently.'),
|
||||
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
||||
css.docBlock('Trash is empty.')
|
||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded or
|
||||
dom.maybe((use) => use(home.currentWSPinnedDocs).length > 0, () => [
|
||||
css.docListHeader(css.docHeaderIconDark('PinBig'), 'Pinned Documents'),
|
||||
createPinnedDocs(home, home.currentWSPinnedDocs),
|
||||
]),
|
||||
|
||||
// Build the featured templates dom if on the Examples & Templates page.
|
||||
dom.maybe((use) => page === 'templates' && use(home.featuredTemplates).length > 0, () => [
|
||||
css.featuredTemplatesHeader(
|
||||
css.featuredTemplatesIcon('Idea'),
|
||||
'Featured',
|
||||
testId('featured-templates-header')
|
||||
),
|
||||
createPinnedDocs(home, home.featuredTemplates, true),
|
||||
]),
|
||||
|
||||
dom.maybe(home.available, () => [
|
||||
buildOtherSites(home),
|
||||
(showIntro && page === 'all' ?
|
||||
null :
|
||||
css.docListHeader(
|
||||
(
|
||||
page === 'all' ? 'All Documents' :
|
||||
page === 'templates' ?
|
||||
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
|
||||
hasFeaturedTemplates ? 'More Examples & Templates' : 'Examples & Templates'
|
||||
) :
|
||||
page === 'trash' ? 'Trash' :
|
||||
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
|
||||
),
|
||||
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
||||
) :
|
||||
(page === 'templates') ?
|
||||
dom('div',
|
||||
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace ?
|
||||
css.docBlock(
|
||||
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
|
||||
testId('doc-block')
|
||||
testId('doc-header'),
|
||||
)
|
||||
),
|
||||
(
|
||||
(page === 'all') ?
|
||||
dom('div',
|
||||
showIntro ? buildHomeIntro(home) : null,
|
||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||
dom.maybe(use => use(isNarrowScreenObs()), () => createUpgradeNudge(home)),
|
||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||
) :
|
||||
css.docBlock('Workspace not found')
|
||||
)
|
||||
]),
|
||||
];
|
||||
}),
|
||||
testId('doclist')
|
||||
(page === 'trash') ?
|
||||
dom('div',
|
||||
css.docBlock('Documents stay in Trash for 30 days, after which they get deleted permanently.'),
|
||||
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
||||
css.docBlock('Trash is empty.')
|
||||
),
|
||||
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
||||
) :
|
||||
(page === 'templates') ?
|
||||
dom('div',
|
||||
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
|
||||
) :
|
||||
workspace && !workspace.isSupportWorkspace ?
|
||||
css.docBlock(
|
||||
buildWorkspaceDocBlock(home, workspace, flashDocId, viewSettings),
|
||||
testId('doc-block')
|
||||
) :
|
||||
css.docBlock('Workspace not found')
|
||||
)
|
||||
]),
|
||||
];
|
||||
}),
|
||||
testId('doclist')
|
||||
),
|
||||
dom.maybe(use => !use(isNarrowScreenObs()) && use(home.currentPage) === 'all', () => createUpgradeNudge(home)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -407,7 +428,7 @@ async function doRename(home: HomeModel, doc: Document, val: string, flashDocId:
|
||||
flashDocId.set(doc.id);
|
||||
flashDocId.set(null);
|
||||
} catch (err) {
|
||||
reportError(err);
|
||||
reportError(err as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,3 +586,7 @@ function shouldShowTemplates(home: HomeModel, showIntro: boolean): boolean {
|
||||
// Show templates for all personal orgs, and for non-personal orgs when showing intro.
|
||||
return isPersonalOrg || showIntro;
|
||||
}
|
||||
|
||||
const cssDocMenu = styled('div', `
|
||||
flex-grow: 1;
|
||||
`);
|
||||
|
||||
@@ -14,6 +14,7 @@ export const docList = styled('div', `
|
||||
padding: 32px 64px 24px 64px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {commonUrls, getSingleOrg, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {getSingleOrg, shouldHideUiElement} from 'app/common/gristUrls';
|
||||
import {getOrgName} from 'app/common/UserAPI';
|
||||
import {dom, makeTestId, styled} from 'grainjs';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {menuDivider, menuIcon, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
||||
import {menuDivider, menuIcon, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {colors} from 'app/client/ui2018/cssVars';
|
||||
|
||||
@@ -39,8 +39,8 @@ export function buildSiteSwitcher(appModel: AppModel) {
|
||||
testId('org'),
|
||||
)
|
||||
),
|
||||
menuItemLink(
|
||||
{ href: commonUrls.createTeamSite },
|
||||
menuItem(
|
||||
() => appModel.showNewSiteModal(),
|
||||
menuIcon('Plus'),
|
||||
'Create new team site',
|
||||
testId('create-new-site'),
|
||||
|
||||
@@ -100,6 +100,7 @@ export type IconName = "ChartArea" |
|
||||
"Settings" |
|
||||
"Share" |
|
||||
"Sort" |
|
||||
"Sparks" |
|
||||
"Tick" |
|
||||
"TickSolid" |
|
||||
"Undo" |
|
||||
@@ -222,6 +223,7 @@ export const IconList: IconName[] = ["ChartArea",
|
||||
"Settings",
|
||||
"Share",
|
||||
"Sort",
|
||||
"Sparks",
|
||||
"Tick",
|
||||
"TickSolid",
|
||||
"Undo",
|
||||
|
||||
@@ -4,14 +4,18 @@ import {StringUnion} from 'app/common/StringUnion';
|
||||
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
||||
import {BillingAccount, ManagerDelta, OrganizationWithoutAccessInfo} from 'app/common/UserAPI';
|
||||
|
||||
export const BillingSubPage = StringUnion('payment', 'plans');
|
||||
export const BillingSubPage = StringUnion('payment');
|
||||
export type BillingSubPage = typeof BillingSubPage.type;
|
||||
|
||||
export const BillingPage = StringUnion(...BillingSubPage.values, 'billing');
|
||||
export type BillingPage = typeof BillingPage.type;
|
||||
|
||||
export const BillingTask = StringUnion('signUp', 'signUpLite', 'updatePlan', 'addCard',
|
||||
'updateCard', 'updateAddress', 'updateDomain');
|
||||
// updateDomain - it is a subpage for billing page, to update domain name.
|
||||
// The rest are for payment page:
|
||||
// signUpLite - it is a subpage for payment, to finalize (complete) signup process
|
||||
// and set domain and team name when they are not set yet (currently only from landing pages).
|
||||
// signUp - it is landing page for new team sites (it doesn't ask for the name of the team)
|
||||
export const BillingTask = StringUnion('signUpLite', 'updateDomain', 'signUp', 'cancelPlan');
|
||||
export type BillingTask = typeof BillingTask.type;
|
||||
|
||||
// Note that IBillingPlan includes selected fields from the Stripe plan object along with
|
||||
@@ -36,24 +40,11 @@ export interface IBillingPlan {
|
||||
};
|
||||
trial_period_days: number|null; // Number of days in the trial period, or null if there is none.
|
||||
product: string; // the Stripe product id.
|
||||
}
|
||||
|
||||
// Stripe customer address information. Used to maintain the company address.
|
||||
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
|
||||
export interface IBillingAddress {
|
||||
line1: string|null;
|
||||
line2: string|null;
|
||||
city: string|null;
|
||||
state: string|null;
|
||||
postal_code: string|null;
|
||||
country: string|null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Utility type that requires all properties to be non-nullish.
|
||||
type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
|
||||
|
||||
// Filled address info from the client. Fields can be blank strings.
|
||||
export type IFilledBillingAddress = NonNullableProperties<IBillingAddress>;
|
||||
// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
|
||||
|
||||
// Stripe promotion code and coupon information. Used by client to apply signup discounts.
|
||||
// For reference: https://stripe.com/docs/api/promotion_codes/object#promotion_code_object-coupon
|
||||
@@ -74,13 +65,6 @@ export interface IBillingDiscount {
|
||||
end_timestamp_ms: number|null;
|
||||
}
|
||||
|
||||
export interface IBillingCard {
|
||||
funding?: string|null;
|
||||
brand?: string|null;
|
||||
country?: string|null; // uppercase two-letter ISO country code
|
||||
last4?: string|null; // last 4 digits of the card number
|
||||
name?: string|null;
|
||||
}
|
||||
|
||||
export interface IBillingSubscription {
|
||||
// All standard plan options.
|
||||
@@ -98,10 +82,6 @@ export interface IBillingSubscription {
|
||||
// Value in cents remaining for the current subscription. This indicates the amount that
|
||||
// will be discounted from a subscription upgrade.
|
||||
valueRemaining: number;
|
||||
// The payment card, or null if none is attached.
|
||||
card: IBillingCard|null;
|
||||
// The company address.
|
||||
address: IBillingAddress|null;
|
||||
// The effective tax rate of the customer for the given address.
|
||||
taxRate: number;
|
||||
// The current number of users with whom the paid org is shared.
|
||||
@@ -112,8 +92,18 @@ export interface IBillingSubscription {
|
||||
discount: IBillingDiscount|null;
|
||||
// Last plan we had a subscription for, if any.
|
||||
lastPlanId: string|null;
|
||||
// Whether there is a valid plan in effect
|
||||
// Whether there is a valid plan in effect.
|
||||
isValidPlan: boolean;
|
||||
// A flag for when all is well with the user's subscription.
|
||||
inGoodStanding: boolean;
|
||||
// Whether there is a paying valid account (even on free plan). It this is set
|
||||
// user needs to upgrade the plan using Stripe Customer portal. In not, we need to
|
||||
// go though checkout process.
|
||||
activeSubscription: boolean;
|
||||
// Whether the plan is billable. Billable plans must be in Stripe.
|
||||
billable: boolean;
|
||||
// Whether we are waiting for upgrade to complete.
|
||||
upgradingPlanIndex: number;
|
||||
|
||||
// Stripe status, documented at https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
||||
// such as "active", "trialing" (reflected in isInTrial), "incomplete", etc.
|
||||
@@ -136,24 +126,18 @@ export interface FullBillingAccount extends BillingAccount {
|
||||
|
||||
export interface BillingAPI {
|
||||
isDomainAvailable(domain: string): Promise<boolean>;
|
||||
getCoupon(promotionCode: string): Promise<IBillingCoupon>;
|
||||
getTaxRate(address: IBillingAddress): Promise<number>;
|
||||
getPlans(): Promise<IBillingPlan[]>;
|
||||
getSubscription(): Promise<IBillingSubscription>;
|
||||
getBillingAccount(): Promise<FullBillingAccount>;
|
||||
// The signUp function takes the tokenId generated when card data is submitted to Stripe.
|
||||
// See: https://stripe.com/docs/stripe-js/reference#stripe-create-token
|
||||
signUp(planId: string, tokenId: string, address: IBillingAddress,
|
||||
settings: IBillingOrgSettings, promotionCode?: string): Promise<OrganizationWithoutAccessInfo>;
|
||||
setCard(tokenId: string): Promise<void>;
|
||||
removeCard(): Promise<void>;
|
||||
setSubscription(planId: string, options: {
|
||||
tokenId?: string,
|
||||
address?: IBillingAddress,
|
||||
settings?: IBillingOrgSettings,
|
||||
}): Promise<void>;
|
||||
updateAddress(address?: IBillingAddress, settings?: IBillingOrgSettings): Promise<void>;
|
||||
updateBillingManagers(delta: ManagerDelta): Promise<void>;
|
||||
updateSettings(settings: IBillingOrgSettings): Promise<void>;
|
||||
subscriptionStatus(planId: string): Promise<boolean>;
|
||||
createFreeTeam(name: string, domain: string): Promise<string>;
|
||||
createTeam(name: string, domain: string): Promise<string>;
|
||||
upgrade(): Promise<string>;
|
||||
cancelCurrentPlan(): Promise<void>;
|
||||
renewPlan(): string;
|
||||
customerPortal(): string;
|
||||
}
|
||||
|
||||
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
@@ -167,20 +151,6 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
}
|
||||
|
||||
public async getCoupon(promotionCode: string): Promise<IBillingCoupon> {
|
||||
return this.requestJson(`${this._url}/api/billing/coupon/${promotionCode}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
public async getTaxRate(address: IBillingAddress): Promise<number> {
|
||||
return this.requestJson(`${this._url}/api/billing/tax`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address })
|
||||
});
|
||||
}
|
||||
|
||||
public async getPlans(): Promise<IBillingPlan[]> {
|
||||
return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'});
|
||||
}
|
||||
@@ -194,50 +164,17 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
return this.requestJson(`${this._url}/api/billing`, {method: 'GET'});
|
||||
}
|
||||
|
||||
// Returns the new Stripe customerId.
|
||||
public async signUp(
|
||||
planId: string,
|
||||
tokenId: string,
|
||||
address: IBillingAddress,
|
||||
settings: IBillingOrgSettings,
|
||||
promotionCode?: string,
|
||||
): Promise<OrganizationWithoutAccessInfo> {
|
||||
const parsed = await this.requestJson(`${this._url}/api/billing/signup`, {
|
||||
public async cancelCurrentPlan() {
|
||||
await this.request(`${this._url}/api/billing/cancel-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tokenId, planId, address, settings, promotionCode }),
|
||||
});
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
public async setSubscription(planId: string, options: {
|
||||
tokenId?: string,
|
||||
address?: IBillingAddress,
|
||||
}): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/subscription`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...options, planId })
|
||||
});
|
||||
}
|
||||
|
||||
public async removeSubscription(): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/subscription`, {method: 'DELETE'});
|
||||
}
|
||||
|
||||
public async setCard(tokenId: string): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/card`, {
|
||||
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tokenId })
|
||||
});
|
||||
}
|
||||
|
||||
public async removeCard(): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/card`, {method: 'DELETE'});
|
||||
}
|
||||
|
||||
public async updateAddress(address?: IBillingAddress, settings?: IBillingOrgSettings): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address, settings })
|
||||
body: JSON.stringify({ settings })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +185,55 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
});
|
||||
}
|
||||
|
||||
public async createFreeTeam(name: string, domain: string): Promise<string> {
|
||||
const data = await this.requestJson(`${this._url}/api/billing/team-free`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
name
|
||||
})
|
||||
});
|
||||
return data.orgUrl;
|
||||
}
|
||||
|
||||
public async createTeam(name: string, domain: string): Promise<string> {
|
||||
const data = await this.requestJson(`${this._url}/api/billing/team`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
name,
|
||||
planType: 'team'
|
||||
})
|
||||
});
|
||||
return data.checkoutUrl;
|
||||
}
|
||||
|
||||
public async upgrade(): Promise<string> {
|
||||
const data = await this.requestJson(`${this._url}/api/billing/upgrade`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return data.checkoutUrl;
|
||||
}
|
||||
|
||||
public customerPortal(): string {
|
||||
return `${this._url}/api/billing/customer-portal`;
|
||||
}
|
||||
|
||||
public renewPlan(): string {
|
||||
return `${this._url}/api/billing/renew`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current org has active subscription for a Stripe plan.
|
||||
*/
|
||||
public async subscriptionStatus(planId: string): Promise<boolean> {
|
||||
const data = await this.requestJson(`${this._url}/api/billing/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({planId})
|
||||
});
|
||||
return data.active;
|
||||
}
|
||||
|
||||
private get _url(): string {
|
||||
return addCurrentOrgToPath(this._homeUrl);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Product {
|
||||
features: Features;
|
||||
}
|
||||
|
||||
|
||||
// A product is essentially a list of flags and limits that we may enforce/support.
|
||||
export interface Features {
|
||||
vanityDomain?: boolean; // are user-selected domains allowed (unenforced) (default: true)
|
||||
@@ -69,5 +70,5 @@ export function canAddOrgMembers(features: Features): boolean {
|
||||
|
||||
// Returns true if `product` is free.
|
||||
export function isFreeProduct(product: Product): boolean {
|
||||
return ['starter', 'teamFree'].includes(product.name);
|
||||
return ['starter', 'teamFree', 'Free'].includes(product?.name);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface UserOrgPrefs extends Prefs {
|
||||
|
||||
// List of document IDs where the user has seen and dismissed the document tour.
|
||||
seenDocTours?: string[];
|
||||
|
||||
// Whether the user seen the nudge to upgrade to Free Team Site and dismissed it.
|
||||
seenFreeTeamUpgradeNudge?: boolean;
|
||||
}
|
||||
|
||||
export type OrgPrefs = Prefs;
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface BillingAccount {
|
||||
individual: boolean;
|
||||
product: Product;
|
||||
isManager: boolean;
|
||||
inGoodStanding: boolean;
|
||||
externalOptions?: {
|
||||
invoiceId?: string;
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export function addOrg(
|
||||
userId: number,
|
||||
props: Partial<OrganizationProperties>,
|
||||
options?: {
|
||||
planType?: 'free'
|
||||
planType?: string,
|
||||
}
|
||||
): Promise<number> {
|
||||
return dbManager.connection.transaction(async manager => {
|
||||
|
||||
@@ -98,6 +98,7 @@ export const PRODUCTS: IProduct[] = [
|
||||
},
|
||||
|
||||
// These are products set up in stripe.
|
||||
// TODO: this is not true anymore
|
||||
{
|
||||
name: 'starter',
|
||||
features: starterFeatures,
|
||||
@@ -108,21 +109,22 @@ export const PRODUCTS: IProduct[] = [
|
||||
},
|
||||
{
|
||||
name: 'team',
|
||||
features: teamFeatures,
|
||||
features: teamFeatures
|
||||
},
|
||||
|
||||
// This is a product for a team site that is no longer in good standing, but isn't yet
|
||||
// to be removed / deactivated entirely.
|
||||
{
|
||||
name: 'suspended',
|
||||
features: suspendedFeatures,
|
||||
features: suspendedFeatures
|
||||
},
|
||||
{
|
||||
name: 'teamFree',
|
||||
features: teamFreeFeatures,
|
||||
features: teamFreeFeatures
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Get names of products for different situations.
|
||||
*/
|
||||
|
||||
@@ -823,6 +823,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
features: starterFeatures,
|
||||
},
|
||||
isManager: false,
|
||||
inGoodStanding: true,
|
||||
},
|
||||
host: null
|
||||
};
|
||||
@@ -1263,6 +1264,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
* @param useNewPlan: by default, the individual billing account associated with the
|
||||
* user's personal org will be used for all other orgs they create. Set useNewPlan
|
||||
* to force a distinct non-individual billing account to be used for this org.
|
||||
* NOTE: Currently it is always a true - billing account is one to one with org.
|
||||
* @param planType: if set, controls the type of plan used for the org. Only
|
||||
* meaningful for team sites currently.
|
||||
*
|
||||
@@ -1270,7 +1272,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
public async addOrg(user: User, props: Partial<OrganizationProperties>,
|
||||
options: { setUserAsOwner: boolean,
|
||||
useNewPlan: boolean,
|
||||
planType?: 'free',
|
||||
planType?: string,
|
||||
externalId?: string,
|
||||
externalOptions?: ExternalBillingOptions },
|
||||
transaction?: EntityManager): Promise<QueryResult<number>> {
|
||||
@@ -1297,13 +1299,15 @@ export class HomeDBManager extends EventEmitter {
|
||||
// Create or find a billing account to associate with this org.
|
||||
const billingAccountEntities = [];
|
||||
let billingAccount;
|
||||
if (options.useNewPlan) {
|
||||
if (options.useNewPlan) { // use separate billing account (currently yes)
|
||||
const productNames = getDefaultProductNames();
|
||||
let productName = options.setUserAsOwner ? productNames.personal :
|
||||
options.planType === 'free' ? productNames.teamFree : productNames.teamInitial;
|
||||
options.planType === productNames.teamFree ? productNames.teamFree : productNames.teamInitial;
|
||||
// A bit fragile: this is called during creation of support@ user, before
|
||||
// getSupportUserId() is available, but with setUserAsOwner of true.
|
||||
if (!options.setUserAsOwner && user.id === this.getSupportUserId() && options.planType !== 'free') {
|
||||
if (!options.setUserAsOwner
|
||||
&& user.id === this.getSupportUserId()
|
||||
&& options.planType !== productNames.teamFree) {
|
||||
// For teams created by support@getgrist.com, set the product to something
|
||||
// good so payment not needed. This is useful for testing.
|
||||
productName = productNames.team;
|
||||
@@ -1328,6 +1332,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
billingAccount.externalOptions = options.externalOptions;
|
||||
}
|
||||
} else {
|
||||
log.warn("Creating org with shared billing account");
|
||||
// Use the billing account from the user's personal org to start with.
|
||||
billingAccount = await manager.createQueryBuilder()
|
||||
.select('billing_accounts')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {BillingTask} from 'app/common/BillingAPI';
|
||||
import {delay} from 'app/common/delay';
|
||||
import {DocCreationInfo} from 'app/common/DocListAPI';
|
||||
import {encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState, isOrgInPathOnly,
|
||||
@@ -7,7 +6,7 @@ import {getOrgUrlInfo} from 'app/common/gristUrls';
|
||||
import {UserProfile} from 'app/common/LoginSessionAPI';
|
||||
import {tbind} from 'app/common/tbind';
|
||||
import * as version from 'app/common/version';
|
||||
import {ApiServer} from 'app/gen-server/ApiServer';
|
||||
import {ApiServer, getOrgFromRequest} from 'app/gen-server/ApiServer';
|
||||
import {Document} from "app/gen-server/entity/Document";
|
||||
import {Organization} from "app/gen-server/entity/Organization";
|
||||
import {Workspace} from 'app/gen-server/entity/Workspace';
|
||||
@@ -70,6 +69,7 @@ import {AddressInfo} from 'net';
|
||||
import fetch from 'node-fetch';
|
||||
import * as path from 'path';
|
||||
import * as serveStatic from "serve-static";
|
||||
import {BillingTask} from 'app/common/BillingAPI';
|
||||
|
||||
// Health checks are a little noisy in the logs, so we don't show them all.
|
||||
// We show the first N health checks:
|
||||
@@ -565,7 +565,7 @@ export class FlexServer implements GristServer {
|
||||
public addBillingApi() {
|
||||
if (this._check('billing-api', 'homedb', 'json', 'api-mw')) { return; }
|
||||
this._getBilling();
|
||||
this._billing.addEndpoints(this.app);
|
||||
this._billing.addEndpoints(this.app, this);
|
||||
this._billing.addEventHandlers();
|
||||
}
|
||||
|
||||
@@ -685,7 +685,7 @@ export class FlexServer implements GristServer {
|
||||
await axios.get(statusUrl);
|
||||
return w.data;
|
||||
} catch (err) {
|
||||
log.debug(`While waiting for ${statusUrl} got error ${err.message}`);
|
||||
log.debug(`While waiting for ${statusUrl} got error ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Cannot connect to ${statusUrl}`);
|
||||
@@ -759,6 +759,8 @@ export class FlexServer implements GristServer {
|
||||
}
|
||||
if (mreq.org && mreq.org.startsWith('o-')) {
|
||||
// We are on a team site without a custom subdomain.
|
||||
const orgInfo = this._dbManager.unwrapQueryResult(await this._dbManager.getOrg({userId: user.id}, mreq.org));
|
||||
|
||||
// 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.
|
||||
@@ -769,10 +771,9 @@ export class FlexServer implements GristServer {
|
||||
// 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`);
|
||||
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : '';
|
||||
return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`);
|
||||
}
|
||||
}
|
||||
next();
|
||||
@@ -1091,6 +1092,16 @@ export class FlexServer implements GristServer {
|
||||
this._redirectToLoginWithoutExceptionsMiddleware
|
||||
];
|
||||
|
||||
function getPrefix(req: express.Request) {
|
||||
const org = getOrgFromRequest(req);
|
||||
if (!org) {
|
||||
return getOriginUrl(req);
|
||||
}
|
||||
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${org}` : '';
|
||||
return prefix;
|
||||
}
|
||||
|
||||
// Add billing summary page (.../billing)
|
||||
this.app.get('/billing', ...middleware, expressWrap(async (req, resp, next) => {
|
||||
const mreq = req as RequestWithLogin;
|
||||
const orgDomain = mreq.org;
|
||||
@@ -1109,20 +1120,31 @@ export class FlexServer implements GristServer {
|
||||
}));
|
||||
|
||||
this.app.get('/billing/payment', ...middleware, expressWrap(async (req, resp, next) => {
|
||||
const task = optStringParam(req.query.billingTask) || '';
|
||||
const planRequired = task === 'signup' || task === 'updatePlan';
|
||||
if (!BillingTask.guard(task) || (planRequired && !req.query.billingPlan)) {
|
||||
// If the payment task/plan are invalid, redirect to the summary page.
|
||||
const task = (optStringParam(req.query.billingTask) || '') as BillingTask;
|
||||
if (!BillingTask.guard(task)) {
|
||||
// If the payment task are invalid, redirect to the summary page.
|
||||
return resp.redirect(getOriginUrl(req) + `/billing`);
|
||||
} else {
|
||||
return this._sendAppPage(req, resp, {path: 'billing.html', status: 200, config: {}});
|
||||
}
|
||||
}));
|
||||
|
||||
// This endpoint is used only during testing, to support existing tests that
|
||||
// depend on a page that has been removed.
|
||||
this.app.get('/test/support/billing/plans', expressWrap(async (req, resp, next) => {
|
||||
return this._sendAppPage(req, resp, {path: 'billing.html', status: 200, config: {}});
|
||||
/**
|
||||
* Add landing page for creating pro team sites. Creates new org and redirect to Stripe Checkout Page.
|
||||
* @param billingPlan Stripe plan/price id to use. Must be a standard plan that resolves to a billable product.
|
||||
* @param planType Product type to use. Grist will look for a Stripe Product with a default price
|
||||
* that has metadata 'gristProduct' parameter with this plan. If billingPlan is passed, this
|
||||
* parameter is ignored.
|
||||
*/
|
||||
this.app.get('/billing/signup', ...middleware, expressWrap(async (req, resp, next) => {
|
||||
const planType = optStringParam(req.query.planType) || '';
|
||||
const billingPlan = optStringParam(req.query.billingPlan) || '';
|
||||
if (!planType && !billingPlan) {
|
||||
return this._sendAppPage(req, resp, {path: 'error.html', status: 404, config: {errPage: 'not-found'}});
|
||||
}
|
||||
// Redirect to GET endpoint in the billing api to create a team site.
|
||||
const url = `${getPrefix(req)}/api/billing/signup?planType=${planType}&billingPlan=${billingPlan}`;
|
||||
return resp.redirect(url);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1242,12 +1264,17 @@ export class FlexServer implements GristServer {
|
||||
* Get a url for a team site.
|
||||
*/
|
||||
public async getOrgUrl(orgKey: string|number): Promise<string> {
|
||||
const org = await this.getOrg(orgKey);
|
||||
return this.getResourceUrl(org);
|
||||
}
|
||||
|
||||
public async getOrg(orgKey: string|number) {
|
||||
if (!this._dbManager) { throw new Error('database missing'); }
|
||||
const org = await this._dbManager.getOrg({
|
||||
userId: this._dbManager.getPreviewerUserId(),
|
||||
showAll: true
|
||||
}, orgKey);
|
||||
return this.getResourceUrl(this._dbManager.unwrapQueryResult(org));
|
||||
return this._dbManager.unwrapQueryResult(org);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as express from 'express';
|
||||
import {GristServer} from 'app/server/lib/GristServer';
|
||||
|
||||
export interface IBilling {
|
||||
addEndpoints(app: express.Express): void;
|
||||
addEndpoints(app: express.Express, server: GristServer): void;
|
||||
addEventHandlers(): void;
|
||||
addWebhooks(app: express.Express): void;
|
||||
addMiddleware?(app: express.Express): Promise<void>;
|
||||
|
||||
@@ -181,7 +181,7 @@ export async function sendReply<T>(
|
||||
result: QueryResult<T>,
|
||||
options: SendReplyOptions = {},
|
||||
) {
|
||||
const data = pruneAPIResult(result.data || null, options.allowedFields);
|
||||
const data = pruneAPIResult(result.data, options.allowedFields);
|
||||
if (shouldLogApiDetails && req) {
|
||||
const mreq = req as RequestWithLogin;
|
||||
log.rawDebug('api call', {
|
||||
@@ -196,7 +196,7 @@ export async function sendReply<T>(
|
||||
});
|
||||
}
|
||||
if (result.status === 200) {
|
||||
return res.json(data);
|
||||
return res.json(data ?? null); // can't handle undefined
|
||||
} else {
|
||||
return res.status(result.status).json({error: result.errMessage});
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export function pruneAPIResult<T>(data: T, allowedFields?: Set<string>): T {
|
||||
if (key === 'connectId' && value === null) { return undefined; }
|
||||
return INTERNAL_FIELDS.has(key) ? undefined : value;
|
||||
});
|
||||
return JSON.parse(output);
|
||||
return output !== undefined ? JSON.parse(output) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user