mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Customizable stripe plans.
Summary: - Reading plans from Stripe, and allowing Stripe to define custom plans. - Storing product features (aka limits) in Stripe, that override those in db. - Adding hierarchical data in Stripe. All features are defined at Product level but can be overwritten on Price levels. - New options for Support user to -- Override product for team site (if he is added as a billing manager) -- Override subscription and customer id for a team site -- Attach an "offer", an custom plan configured in stripe that a team site can use -- Enabling wire transfer for subscription by allowing subscription to be created without a payment method (which is customizable) Test Plan: Updated and new. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4201
This commit is contained in:
parent
ed9514bae0
commit
60423edc17
@ -35,6 +35,7 @@ export class DocumentUsage extends Disposable {
|
||||
private readonly _currentDocUsage = this._docPageModel.currentDocUsage;
|
||||
private readonly _currentOrg = this._docPageModel.currentOrg;
|
||||
private readonly _currentProduct = this._docPageModel.currentProduct;
|
||||
private readonly _currentFeatures = this._docPageModel.currentFeatures;
|
||||
|
||||
// TODO: Update this whenever the rest of the UI is internationalized.
|
||||
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
|
||||
@ -56,8 +57,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _rowMetricOptions: Computed<MetricOptions> =
|
||||
Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => {
|
||||
const maxRows = product?.features.baseMaxRowsPerDocument;
|
||||
Computed.create(this, this._currentFeatures, this._rowCount, (_use, features, rowCount) => {
|
||||
const maxRows = features?.baseMaxRowsPerDocument;
|
||||
// Invalid row limits are currently treated as if they are undefined.
|
||||
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
|
||||
return {
|
||||
@ -71,8 +72,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _dataSizeMetricOptions: Computed<MetricOptions> =
|
||||
Computed.create(this, this._currentProduct, this._dataSizeBytes, (_use, product, dataSize) => {
|
||||
const maxSize = product?.features.baseMaxDataSizePerDocument;
|
||||
Computed.create(this, this._currentFeatures, this._dataSizeBytes, (_use, features, dataSize) => {
|
||||
const maxSize = features?.baseMaxDataSizePerDocument;
|
||||
// Invalid data size limits are currently treated as if they are undefined.
|
||||
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
|
||||
return {
|
||||
@ -93,8 +94,8 @@ export class DocumentUsage extends Disposable {
|
||||
});
|
||||
|
||||
private readonly _attachmentsSizeMetricOptions: Computed<MetricOptions> =
|
||||
Computed.create(this, this._currentProduct, this._attachmentsSizeBytes, (_use, product, attachmentsSize) => {
|
||||
const maxSize = product?.features.baseMaxAttachmentsBytesPerDocument;
|
||||
Computed.create(this, this._currentFeatures, this._attachmentsSizeBytes, (_use, features, attachmentsSize) => {
|
||||
const maxSize = features?.baseMaxAttachmentsBytesPerDocument;
|
||||
// Invalid attachments size limits are currently treated as if they are undefined.
|
||||
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
|
||||
return {
|
||||
@ -156,11 +157,12 @@ export class DocumentUsage extends Disposable {
|
||||
|
||||
const org = use(this._currentOrg);
|
||||
const product = use(this._currentProduct);
|
||||
const features = use(this._currentFeatures);
|
||||
const status = use(this._dataLimitStatus);
|
||||
if (!org || !status) { return null; }
|
||||
|
||||
return buildMessage([
|
||||
buildLimitStatusMessage(status, product?.features, {
|
||||
buildLimitStatusMessage(status, features, {
|
||||
disableRawDataLink: true
|
||||
}),
|
||||
(product && isFreePlan(product.name)
|
||||
@ -195,7 +197,7 @@ export class DocumentUsage extends Disposable {
|
||||
|
||||
export function buildLimitStatusMessage(
|
||||
status: NonNullable<DataLimitStatus>,
|
||||
features?: Features,
|
||||
features?: Features|null,
|
||||
options: {
|
||||
disableRawDataLink?: boolean;
|
||||
} = {}
|
||||
|
@ -12,9 +12,10 @@ import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrade
|
||||
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
|
||||
import {gristThemePrefs} from 'app/client/ui2018/theme';
|
||||
import {AsyncCreate} from 'app/common/AsyncCreate';
|
||||
import {PlanSelection} from 'app/common/BillingAPI';
|
||||
import {ICustomWidget} from 'app/common/CustomWidget';
|
||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {Features, isLegacyPlan, Product} from 'app/common/Features';
|
||||
import {Features, isFreePlan, isLegacyPlan, mergedFeatures, Product} from 'app/common/Features';
|
||||
import {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import {LocalPlugin} from 'app/common/plugin';
|
||||
@ -112,7 +113,8 @@ export interface AppModel {
|
||||
lastVisitedOrgDomain: Observable<string|null>;
|
||||
|
||||
currentProduct: Product|null; // The current org's product.
|
||||
currentFeatures: Features; // Features of the current org's product.
|
||||
currentPriceId: string|null; // The current org's stripe plan id.
|
||||
currentFeatures: Features|null; // Features of the current org's product.
|
||||
|
||||
userPrefsObs: Observable<UserPrefs>;
|
||||
themePrefs: Observable<ThemePrefs>;
|
||||
@ -133,8 +135,8 @@ export interface AppModel {
|
||||
supportGristNudge: SupportGristNudge;
|
||||
|
||||
refreshOrgUsage(): Promise<void>;
|
||||
showUpgradeModal(): void;
|
||||
showNewSiteModal(): void;
|
||||
showUpgradeModal(): Promise<void>;
|
||||
showNewSiteModal(): Promise<void>;
|
||||
isBillingManager(): boolean; // If user is a billing manager for this org
|
||||
isSupport(): boolean; // If user is a Support user
|
||||
isOwner(): boolean; // If user is an owner of this org
|
||||
@ -142,6 +144,7 @@ export interface AppModel {
|
||||
isInstallAdmin(): boolean; // Is user an admin of this installation
|
||||
dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not.
|
||||
switchUser(user: FullUser, org?: string): Promise<void>;
|
||||
isFreePlan(): boolean;
|
||||
}
|
||||
|
||||
export interface TopAppModelOptions {
|
||||
@ -293,7 +296,11 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
public readonly lastVisitedOrgDomain = this.autoDispose(sessionStorageObs('grist-last-visited-org-domain'));
|
||||
|
||||
public readonly currentProduct = this.currentOrg?.billingAccount?.product ?? null;
|
||||
public readonly currentFeatures = this.currentProduct?.features ?? {};
|
||||
public readonly currentPriceId = this.currentOrg?.billingAccount?.stripePlanId ?? null;
|
||||
public readonly currentFeatures = mergedFeatures(
|
||||
this.currentProduct?.features ?? null,
|
||||
this.currentOrg?.billingAccount?.features ?? null
|
||||
);
|
||||
|
||||
public readonly isPersonal = Boolean(this.currentOrg?.owner);
|
||||
public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;
|
||||
@ -367,7 +374,17 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
if (state.createTeam) {
|
||||
// Remove params from the URL.
|
||||
urlState().pushUrl({createTeam: false, params: {}}, {avoidReload: true, replace: true}).catch(() => {});
|
||||
this.showNewSiteModal(state.params?.planType);
|
||||
this.showNewSiteModal({
|
||||
priceId: state.params?.billingPlan,
|
||||
product: state.params?.planType,
|
||||
}).catch(reportError);
|
||||
} else if (state.upgradeTeam) {
|
||||
// Remove params from the URL.
|
||||
urlState().pushUrl({upgradeTeam: false, params: {}}, {avoidReload: true, replace: true}).catch(() => {});
|
||||
this.showUpgradeModal({
|
||||
priceId: state.params?.billingPlan,
|
||||
product: state.params?.planType,
|
||||
}).catch(reportError);
|
||||
}
|
||||
|
||||
G.window.resetDismissedPopups = (seen = false) => {
|
||||
@ -384,23 +401,28 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
return this.currentProduct?.name ?? null;
|
||||
}
|
||||
|
||||
public async showUpgradeModal() {
|
||||
public async showUpgradeModal(plan?: PlanSelection) {
|
||||
if (this.planName && this.currentOrg) {
|
||||
if (this.isPersonal) {
|
||||
this.showNewSiteModal();
|
||||
await this.showNewSiteModal(plan);
|
||||
} else if (this.isTeamSite) {
|
||||
buildUpgradeModal(this, this.planName);
|
||||
await buildUpgradeModal(this, {
|
||||
appModel: this,
|
||||
pickPlan: plan,
|
||||
reason: 'upgrade'
|
||||
});
|
||||
} else {
|
||||
throw new Error("Unexpected state");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public showNewSiteModal(selectedPlan?: string) {
|
||||
|
||||
public async showNewSiteModal(plan?: PlanSelection) {
|
||||
if (this.planName) {
|
||||
buildNewSiteModal(this, {
|
||||
planName: this.planName,
|
||||
selectedPlan,
|
||||
await buildNewSiteModal(this, {
|
||||
appModel: this,
|
||||
plan,
|
||||
onCreate: () => this.topAppModel.fetchUsersAndOrgs().catch(reportError)
|
||||
});
|
||||
}
|
||||
@ -451,6 +473,10 @@ export class AppModelImpl extends Disposable implements AppModel {
|
||||
this.lastVisitedOrgDomain.set(null);
|
||||
}
|
||||
|
||||
public isFreePlan() {
|
||||
return isFreePlan(this.planName || '');
|
||||
}
|
||||
|
||||
private _updateLastVisitedOrgDomain({doc, org}: IGristUrlState, availableOrgs: Organization[]) {
|
||||
if (
|
||||
!org ||
|
||||
|
@ -19,7 +19,7 @@ import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
|
||||
import {delay} from 'app/common/delay';
|
||||
import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI';
|
||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||
import {Product} from 'app/common/Features';
|
||||
import {Features, mergedFeatures, Product} from 'app/common/Features';
|
||||
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
|
||||
import {getReconnectTimeout} from 'app/common/gutil';
|
||||
import {canEdit, isOwner} from 'app/common/roles';
|
||||
@ -61,6 +61,10 @@ export interface DocPageModel {
|
||||
* changes, or a doc usage message is received from the server.
|
||||
*/
|
||||
currentProduct: Observable<Product|null>;
|
||||
/**
|
||||
* Current features of the product
|
||||
*/
|
||||
currentFeatures: Observable<Features|null>;
|
||||
|
||||
// This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.
|
||||
currentDocId: Observable<string|undefined>;
|
||||
@ -116,6 +120,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
* changes, or a doc usage message is received from the server.
|
||||
*/
|
||||
public readonly currentProduct = Observable.create<Product|null>(this, null);
|
||||
public readonly currentFeatures: Computed<Features|null>;
|
||||
|
||||
public readonly currentUrlId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.urlId : undefined);
|
||||
public readonly currentDocId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.id : undefined);
|
||||
@ -169,6 +174,14 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
||||
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
|
||||
super();
|
||||
|
||||
this.currentFeatures = Computed.create(this, use => {
|
||||
const product = use(this.currentProduct);
|
||||
if (!product) { return null; }
|
||||
const ba = use(this.currentOrg)?.billingAccount?.features ?? {};
|
||||
const merged = mergedFeatures(product.features, ba);
|
||||
return merged;
|
||||
});
|
||||
|
||||
this.autoDispose(subscribe(urlState().state, (use, state) => {
|
||||
const urlId = state.doc;
|
||||
const urlOpenMode = state.mode;
|
||||
|
@ -382,7 +382,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
|
||||
|
||||
// Check if active product allows just a single workspace.
|
||||
function _isSingleWorkspaceMode(app: AppModel): boolean {
|
||||
return app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
return app.currentFeatures?.maxWorkspacesPerOrg === 1;
|
||||
}
|
||||
|
||||
// Returns a default view mode preference. We used to show 'list' for everyone. We now default to
|
||||
|
@ -180,9 +180,9 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
|
||||
) {
|
||||
super();
|
||||
if (this._options.appModel) {
|
||||
const product = this._options.appModel.currentProduct;
|
||||
const features = this._options.appModel.currentFeatures;
|
||||
const {supportEmail} = getGristConfig();
|
||||
this._shareAnnotator = new ShareAnnotator(product, initData, {supportEmail});
|
||||
this._shareAnnotator = new ShareAnnotator(features, initData, {supportEmail});
|
||||
}
|
||||
this.annotate();
|
||||
}
|
||||
|
@ -139,7 +139,7 @@ export class AccountWidget extends Disposable {
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && currentOrg && this._appModel.isTeamSite ?
|
||||
menuItem(() => manageTeamUsers(currentOrg, user, this._appModel.api),
|
||||
menuItem(() => manageTeamUsers({org: currentOrg, user, api: this._appModel.api}),
|
||||
roles.canEditAccess(currentOrg.access) ? t("Manage Team") : t("Access Details"),
|
||||
testId('dm-org-access')) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
|
@ -117,7 +117,7 @@ export class AppHeader extends Disposable {
|
||||
|
||||
// Show 'Organization Settings' when on a home page of a valid org.
|
||||
(!this._docPageModel && this._currentOrg && !this._currentOrg.owner ?
|
||||
menuItem(() => manageTeamUsersApp(this._appModel),
|
||||
menuItem(() => manageTeamUsersApp({app: this._appModel}),
|
||||
'Manage Team', testId('orgmenu-manage-team'),
|
||||
dom.cls('disabled', !roles.canEditAccess(this._currentOrg.access))) :
|
||||
// Don't show on doc pages, or for personal orgs.
|
||||
|
@ -10,6 +10,7 @@ import {IModalControl, modal} from 'app/client/ui2018/modals';
|
||||
import {TEAM_PLAN} from 'app/common/Features';
|
||||
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||
import {UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {PlanSelection} from 'app/common/BillingAPI';
|
||||
import {
|
||||
Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId,
|
||||
Observable, styled
|
||||
@ -19,9 +20,9 @@ import { makeT } from '../lib/localization';
|
||||
const t = makeT('CreateTeamModal');
|
||||
const testId = makeTestId('test-create-team-');
|
||||
|
||||
export function buildNewSiteModal(context: Disposable, options: {
|
||||
planName: string,
|
||||
selectedPlan?: string,
|
||||
export async function buildNewSiteModal(context: Disposable, options: {
|
||||
appModel: AppModel,
|
||||
plan?: PlanSelection,
|
||||
onCreate?: () => void
|
||||
}) {
|
||||
const { onCreate } = options;
|
||||
@ -78,7 +79,11 @@ class NewSiteModalContent extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildUpgradeModal(owner: Disposable, planName: string): void {
|
||||
export function buildUpgradeModal(owner: Disposable, options: {
|
||||
appModel: AppModel,
|
||||
pickPlan?: PlanSelection,
|
||||
reason?: 'upgrade' | 'renew',
|
||||
}): Promise<void> {
|
||||
throw new UserError(t(`Billing is not supported in grist-core`));
|
||||
}
|
||||
|
||||
|
@ -83,7 +83,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
css.docMenu(
|
||||
attachAddNewTip(home),
|
||||
|
||||
dom.maybe(!home.app.currentFeatures.workspaces, () => [
|
||||
dom.maybe(!home.app.currentFeatures?.workspaces, () => [
|
||||
css.docListHeader(t("This service is not available right now")),
|
||||
dom('span', t("(The organization needs a paid plan)")),
|
||||
]),
|
||||
|
@ -168,7 +168,7 @@ function buildButtons(homeModel: HomeModel, options: {
|
||||
!options.invite ? null :
|
||||
cssBtn(cssBtnIcon('Help'), t("Invite Team Members"), testId('intro-invite'),
|
||||
cssButton.cls('-primary'),
|
||||
dom.on('click', () => manageTeamUsersApp(homeModel.app)),
|
||||
dom.on('click', () => manageTeamUsersApp({app: homeModel.app})),
|
||||
),
|
||||
!options.templates ? null :
|
||||
cssBtn(cssBtnIcon('FieldTable'), t("Browse Templates"), testId('intro-templates'),
|
||||
|
@ -196,7 +196,7 @@ export async function importFromPluginAndOpen(home: HomeModel, source: ImportSou
|
||||
function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] {
|
||||
const org = home.app.currentOrg;
|
||||
const orgAccess: roles.Role|null = org ? org.access : null;
|
||||
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
const needUpgrade = home.app.currentFeatures?.maxWorkspacesPerOrg === 1;
|
||||
|
||||
return [
|
||||
menuItem(() => createDocAndOpen(home), menuIcon('Page'), t("Create Empty Document"),
|
||||
@ -261,7 +261,7 @@ function workspaceMenu(home: HomeModel, ws: Workspace, renaming: Observable<Work
|
||||
});
|
||||
}
|
||||
|
||||
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1;
|
||||
const needUpgrade = home.app.currentFeatures?.maxWorkspacesPerOrg === 1;
|
||||
|
||||
return [
|
||||
upgradableMenuItem(needUpgrade, () => renaming.set(ws), t("Rename"),
|
||||
|
@ -140,8 +140,8 @@ class SaveCopyModal extends Disposable {
|
||||
}
|
||||
// We won't have info about any other org except the one we are at.
|
||||
if (org.id === this._app.currentOrg?.id) {
|
||||
const workspaces = this._app.currentOrg.billingAccount?.product.features.workspaces ?? true;
|
||||
const numberAllowed = this._app.currentOrg.billingAccount?.product.features.maxWorkspacesPerOrg ?? 2;
|
||||
const workspaces = this._app.currentFeatures?.workspaces ?? true;
|
||||
const numberAllowed = this._app.currentFeatures?.maxWorkspacesPerOrg ?? 2;
|
||||
return workspaces && numberAllowed > 1;
|
||||
}
|
||||
return true;
|
||||
|
@ -2,20 +2,33 @@ import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {FullUser, Organization, UserAPI} from 'app/common/UserAPI';
|
||||
|
||||
export interface ManageTeamUsersOptions {
|
||||
org: Organization;
|
||||
user: FullUser | null;
|
||||
api: UserAPI;
|
||||
onSave?: (personal: boolean) => Promise<unknown>;
|
||||
}
|
||||
|
||||
// Opens the user-manager for the given org.
|
||||
export async function manageTeamUsers(org: Organization, user: FullUser|null, api: UserAPI) {
|
||||
export async function manageTeamUsers({org, user, api, onSave}: ManageTeamUsersOptions) {
|
||||
(await loadUserManager()).showUserManagerModal(api, {
|
||||
permissionData: api.getOrgAccess(org.id),
|
||||
activeUser: user,
|
||||
resourceType: 'organization',
|
||||
resourceId: org.id,
|
||||
resource: org,
|
||||
onSave
|
||||
});
|
||||
}
|
||||
|
||||
export interface ManagePersonalUsersAppOptions {
|
||||
app: AppModel;
|
||||
onSave?: (personal: boolean) => Promise<unknown>;
|
||||
}
|
||||
|
||||
// Opens the user-manager for the current org in the given AppModel.
|
||||
export async function manageTeamUsersApp(app: AppModel) {
|
||||
export async function manageTeamUsersApp({app, onSave}: ManagePersonalUsersAppOptions) {
|
||||
if (app.currentOrg) {
|
||||
return manageTeamUsers(app.currentOrg, app.currentValidUser, app.api);
|
||||
return manageTeamUsers({org: app.currentOrg, user: app.currentValidUser, api: app.api, onSave});
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, style
|
||||
|
||||
const t = makeT('TopBar');
|
||||
|
||||
export function createTopBarHome(appModel: AppModel) {
|
||||
export function createTopBarHome(appModel: AppModel, onSave?: (personal: boolean) => Promise<unknown>){
|
||||
const isAnonymous = !appModel.currentValidUser;
|
||||
|
||||
return [
|
||||
@ -32,7 +32,7 @@ export function createTopBarHome(appModel: AppModel) {
|
||||
[
|
||||
basicButton(
|
||||
t("Manage Team"),
|
||||
dom.on('click', () => manageTeamUsersApp(appModel)),
|
||||
dom.on('click', () => manageTeamUsersApp({app: appModel, onSave})),
|
||||
testId('topbar-manage-team')
|
||||
),
|
||||
cssSpacer()
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {DomArg, keyframes, styled} from 'grainjs';
|
||||
import {DomArg, keyframes, Observable, observable, styled} from 'grainjs';
|
||||
|
||||
const rotate360 = keyframes(`
|
||||
from { transform: rotate(45deg); }
|
||||
@ -42,6 +42,21 @@ export function loadingDots(...args: DomArg<HTMLDivElement>[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function watchPromise<T extends (...args: any[]) => any>(fun: T): T & {busy: Observable<boolean>} {
|
||||
const loading = observable(false);
|
||||
const result = async (...args: any) => {
|
||||
loading.set(true);
|
||||
try {
|
||||
return await fun(...args);
|
||||
} finally {
|
||||
if (!loading.isDisposed()) {
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
return Object.assign(result, {busy: loading}) as any;
|
||||
}
|
||||
|
||||
const cssLoadingDotsContainer = styled('div', `
|
||||
--dot-size: 10px;
|
||||
display: inline-flex;
|
||||
|
@ -416,8 +416,8 @@ export function confirmModal(
|
||||
*/
|
||||
export function promptModal(
|
||||
title: string,
|
||||
onConfirm: (text: string) => Promise<unknown>,
|
||||
btnText: string,
|
||||
onConfirm: (text: string) => Promise<void>,
|
||||
btnText?: string,
|
||||
initial?: string,
|
||||
placeholder?: string,
|
||||
onCancel?: () => void
|
||||
@ -429,7 +429,7 @@ export function promptModal(
|
||||
const options: ISaveModalOptions = {
|
||||
title,
|
||||
body: txtInput,
|
||||
saveLabel: btnText,
|
||||
saveLabel: btnText || t('Save'),
|
||||
saveFunc: () => {
|
||||
// Mark that confirm was invoked.
|
||||
confirmed = true;
|
||||
|
@ -381,7 +381,7 @@ export class FormulaAssistant extends Disposable {
|
||||
canUpgradeSite ? t('upgrade to the Pro Team plan') : t('upgrade your plan'),
|
||||
dom.on('click', async () => {
|
||||
if (canUpgradeSite) {
|
||||
this._gristDoc.appModel.showUpgradeModal();
|
||||
this._gristDoc.appModel.showUpgradeModal().catch(reportError);
|
||||
} else {
|
||||
await urlState().pushUrl({billing: 'billing'});
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
|
||||
import {TEAM_FREE_PLAN} from 'app/common/Features';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import {StringUnion} from 'app/common/StringUnion';
|
||||
import {addCurrentOrgToPath} from 'app/common/urlUtils';
|
||||
@ -24,23 +25,22 @@ export type BillingTask = typeof BillingTask.type;
|
||||
export interface IBillingPlan {
|
||||
id: string; // the Stripe plan id
|
||||
nickname: string;
|
||||
currency: string; // lowercase three-letter ISO currency code
|
||||
interval: string; // billing frequency - one of day, week, month or year
|
||||
amount: number; // amount in cents charged at each interval
|
||||
interval: 'day'|'week'|'month'|'year'; // billing frequency - one of day, week, month or year
|
||||
// Merged metadata from price and product.
|
||||
metadata: {
|
||||
family?: string; // groups plans for filtering by GRIST_STRIPE_FAMILY env variable
|
||||
isStandard: boolean; // indicates that the plan should be returned by the API to be offered.
|
||||
supportAvailable: boolean;
|
||||
gristProduct: string; // name of grist product that should be used with this plan.
|
||||
unthrottledApi: boolean;
|
||||
customSubdomain: boolean;
|
||||
workspaces: boolean;
|
||||
maxDocs?: number; // if given, limit of docs that can be created
|
||||
maxUsersPerDoc?: number; // if given, limit of users each doc can be shared with
|
||||
type: string; // type of the plan (either plan or limit for now)
|
||||
minimumUnits?: number; // minimum number of units for the plan
|
||||
gristLimit?: string; // type of the limit (for limit type plans)
|
||||
};
|
||||
trial_period_days: number|null; // Number of days in the trial period, or null if there is none.
|
||||
amount: number; // amount in cents charged at each interval
|
||||
trialPeriodDays: number|null; // Number of days in the trial period, or null if there is none.
|
||||
product: string; // the Stripe product id.
|
||||
features: string[]; // list of features that are available with this plan
|
||||
active: boolean;
|
||||
name: string; // the name of the product
|
||||
}
|
||||
|
||||
export interface ILimitTier {
|
||||
@ -95,16 +95,22 @@ export interface IBillingSubscription {
|
||||
valueRemaining: number;
|
||||
// The effective tax rate of the customer for the given address.
|
||||
taxRate: number;
|
||||
// The current number of seats paid for current billing period.
|
||||
seatCount: number;
|
||||
// The current number of users with whom the paid org is shared.
|
||||
userCount: number;
|
||||
// The next total in cents that Stripe is going to charge (includes tax and discount).
|
||||
nextTotal: number;
|
||||
// The next due date in milliseconds.
|
||||
nextDueDate: number|null; // in milliseconds
|
||||
// Discount information, if any.
|
||||
discount: IBillingDiscount|null;
|
||||
// Last plan we had a subscription for, if any.
|
||||
lastPlanId: string|null;
|
||||
// Whether there is a valid plan in effect.
|
||||
isValidPlan: boolean;
|
||||
// The time when the plan will be cancelled. (Not set when we are switching to a free plan)
|
||||
cancelAt: number|null;
|
||||
// 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
|
||||
@ -119,10 +125,19 @@ export interface IBillingSubscription {
|
||||
// Stripe status, documented at https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
||||
// such as "active", "trialing" (reflected in isInTrial), "incomplete", etc.
|
||||
status?: string;
|
||||
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
|
||||
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
|
||||
lastChargeTime?: number; // The time of the last charge attempt.
|
||||
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
|
||||
lastInvoiceOpen?: boolean; // Whether the last invoice is not paid but it can be.
|
||||
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
|
||||
lastChargeTime?: number; // The time of the last charge attempt.
|
||||
limit?: ILimit|null;
|
||||
balance?: number; // The balance of the account.
|
||||
|
||||
// Current product name. Even if not paid or not in good standing.
|
||||
currentProductName?: string;
|
||||
|
||||
paymentLink?: string; // A link to the payment page for the current plan.
|
||||
paymentOffer?: string; // Optional text to show for the offer.
|
||||
paymentProduct?: string; // The product to show for the offer.
|
||||
}
|
||||
|
||||
export interface ILimit {
|
||||
@ -143,22 +158,72 @@ export interface FullBillingAccount extends BillingAccount {
|
||||
managers: FullUser[];
|
||||
}
|
||||
|
||||
export interface SummaryLine {
|
||||
description: string;
|
||||
quantity?: number|null;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
// Info to show to the user when he changes the plan.
|
||||
export interface ChangeSummary {
|
||||
productName: string,
|
||||
priceId: string,
|
||||
interval: string,
|
||||
quantity: number,
|
||||
type: 'upgrade'|'downgrade',
|
||||
regular: {
|
||||
lines: SummaryLine[];
|
||||
subTotal: number;
|
||||
tax?: number;
|
||||
total: number;
|
||||
periodStart: number;
|
||||
},
|
||||
invoice?: {
|
||||
lines: SummaryLine[];
|
||||
subTotal: number;
|
||||
tax?: number;
|
||||
total: number;
|
||||
appliedBalance: number;
|
||||
amountDue: number;
|
||||
dueDate: number;
|
||||
}
|
||||
}
|
||||
|
||||
export type UpgradeConfirmation = ChangeSummary|{checkoutUrl: string};
|
||||
|
||||
export interface PlanSelection {
|
||||
product?: string; // grist product name
|
||||
priceId?: string; // stripe id of the price
|
||||
offerId?: string; // stripe id of the offer
|
||||
count?: number; // number of units for the plan (suggested as it might be different).
|
||||
}
|
||||
|
||||
export interface BillingAPI {
|
||||
isDomainAvailable(domain: string): Promise<boolean>;
|
||||
getPlans(): Promise<IBillingPlan[]>;
|
||||
getPlans(plan?: PlanSelection): Promise<IBillingPlan[]>;
|
||||
getSubscription(): Promise<IBillingSubscription>;
|
||||
getBillingAccount(): Promise<FullBillingAccount>;
|
||||
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>;
|
||||
createFreeTeam(name: string, domain: string): Promise<void>;
|
||||
createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{
|
||||
checkoutUrl?: string,
|
||||
orgUrl?: string,
|
||||
}>;
|
||||
confirmChange(plan: PlanSelection): Promise<UpgradeConfirmation>;
|
||||
changePlan(plan: PlanSelection): Promise<void>;
|
||||
renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}>;
|
||||
cancelCurrentPlan(): Promise<void>;
|
||||
downgradePlan(planName: string): Promise<void>;
|
||||
renewPlan(): string;
|
||||
customerPortal(): string;
|
||||
updateAssistantPlan(tier: number): Promise<void>;
|
||||
|
||||
changeProduct(product: string): Promise<void>;
|
||||
attachSubscription(subscription: string): Promise<void>;
|
||||
attachPayment(paymentLink: string): Promise<void>;
|
||||
getPaymentLink(): Promise<UpgradeConfirmation>;
|
||||
cancelPlanChange(): Promise<void>;
|
||||
dontCancelPlan(): Promise<void>;
|
||||
}
|
||||
|
||||
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
@ -172,8 +237,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
}
|
||||
public async getPlans(): Promise<IBillingPlan[]> {
|
||||
return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'});
|
||||
public async getPlans(plan?: PlanSelection): Promise<IBillingPlan[]> {
|
||||
const url = new URL(`${this._url}/api/billing/plans`);
|
||||
url.searchParams.set('product', plan?.product || '');
|
||||
url.searchParams.set('priceId', plan?.priceId || '');
|
||||
return this.requestJson(url.href, {
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
|
||||
// Returns an IBillingSubscription
|
||||
@ -191,13 +261,6 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
});
|
||||
}
|
||||
|
||||
public async downgradePlan(planName: string): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/downgrade-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ planName })
|
||||
});
|
||||
}
|
||||
|
||||
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/settings`, {
|
||||
method: 'POST',
|
||||
@ -212,43 +275,53 @@ 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> {
|
||||
public async createTeam(name: string, domain: string, plan: {
|
||||
product?: string, priceId?: string, count?: number
|
||||
}, next?: string): Promise<{
|
||||
checkoutUrl?: string,
|
||||
orgUrl?: string,
|
||||
}> {
|
||||
const data = await this.requestJson(`${this._url}/api/billing/team`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
name,
|
||||
planType: 'team',
|
||||
next: window.location.href
|
||||
...plan,
|
||||
next
|
||||
})
|
||||
});
|
||||
return data.checkoutUrl;
|
||||
return data;
|
||||
}
|
||||
|
||||
public async upgrade(): Promise<string> {
|
||||
const data = await this.requestJson(`${this._url}/api/billing/upgrade`, {
|
||||
method: 'POST',
|
||||
public async createFreeTeam(name: string, domain: string): Promise<void> {
|
||||
await this.createTeam(name, domain, {
|
||||
product: TEAM_FREE_PLAN,
|
||||
});
|
||||
}
|
||||
|
||||
public async changePlan(plan: PlanSelection): Promise<void> {
|
||||
await this.requestJson(`${this._url}/api/billing/change-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(plan)
|
||||
});
|
||||
}
|
||||
|
||||
public async confirmChange(plan: PlanSelection): Promise<ChangeSummary|{checkoutUrl: string}> {
|
||||
return this.requestJson(`${this._url}/api/billing/confirm-change`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(plan)
|
||||
});
|
||||
return data.checkoutUrl;
|
||||
}
|
||||
|
||||
public customerPortal(): string {
|
||||
return `${this._url}/api/billing/customer-portal`;
|
||||
}
|
||||
|
||||
public renewPlan(): string {
|
||||
return `${this._url}/api/billing/renew`;
|
||||
public renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}> {
|
||||
return this.requestJson(`${this._url}/api/billing/renew`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(plan)
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAssistantPlan(tier: number): Promise<void> {
|
||||
@ -269,6 +342,39 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
||||
return data.active;
|
||||
}
|
||||
|
||||
public async changeProduct(product: string): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/change-product`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ product })
|
||||
});
|
||||
}
|
||||
|
||||
public async attachSubscription(subscriptionId: string): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/attach-subscription`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ subscriptionId })
|
||||
});
|
||||
}
|
||||
|
||||
public async attachPayment(paymentLink: string): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/attach-payment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentLink })
|
||||
});
|
||||
}
|
||||
|
||||
public async getPaymentLink(): Promise<{checkoutUrl: string}> {
|
||||
return await this.requestJson(`${this._url}/api/billing/payment-link`, {method: 'GET'});
|
||||
}
|
||||
|
||||
public async cancelPlanChange(): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/cancel-plan-change`, {method: 'POST'});
|
||||
}
|
||||
|
||||
public async dontCancelPlan(): Promise<void> {
|
||||
await this.request(`${this._url}/api/billing/dont-cancel-plan`, {method: 'POST'});
|
||||
}
|
||||
|
||||
private get _url(): string {
|
||||
return addCurrentOrgToPath(this._homeUrl);
|
||||
}
|
||||
|
52
app/common/Features-ti.ts
Normal file
52
app/common/Features-ti.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* This module was automatically generated by `ts-interface-builder`
|
||||
*/
|
||||
import * as t from "ts-interface-checker";
|
||||
// tslint:disable:object-literal-key-quotes
|
||||
|
||||
export const SnapshotWindow = t.iface([], {
|
||||
"count": "number",
|
||||
"unit": t.union(t.lit('days'), t.lit('month'), t.lit('year')),
|
||||
});
|
||||
|
||||
export const Product = t.iface([], {
|
||||
"name": "string",
|
||||
"features": "Features",
|
||||
});
|
||||
|
||||
export const Features = t.iface([], {
|
||||
"vanityDomain": t.opt("boolean"),
|
||||
"workspaces": t.opt("boolean"),
|
||||
"maxSharesPerDoc": t.opt("number"),
|
||||
"maxSharesPerDocPerRole": t.opt(t.iface([], {
|
||||
[t.indexKey]: "number",
|
||||
})),
|
||||
"maxSharesPerWorkspace": t.opt("number"),
|
||||
"maxDocsPerOrg": t.opt("number"),
|
||||
"maxWorkspacesPerOrg": t.opt("number"),
|
||||
"readOnlyDocs": t.opt("boolean"),
|
||||
"snapshotWindow": t.opt("SnapshotWindow"),
|
||||
"baseMaxRowsPerDocument": t.opt("number"),
|
||||
"baseMaxApiUnitsPerDocumentPerDay": t.opt("number"),
|
||||
"baseMaxDataSizePerDocument": t.opt("number"),
|
||||
"baseMaxAttachmentsBytesPerDocument": t.opt("number"),
|
||||
"gracePeriodDays": t.opt("number"),
|
||||
"baseMaxAssistantCalls": t.opt("number"),
|
||||
"minimumUnits": t.opt("number"),
|
||||
});
|
||||
|
||||
export const StripeMetaValues = t.iface([], {
|
||||
"isStandard": t.opt("boolean"),
|
||||
"gristProduct": t.opt("string"),
|
||||
"gristLimit": t.opt("string"),
|
||||
"family": t.opt("string"),
|
||||
"trialPeriodDays": t.opt("number"),
|
||||
});
|
||||
|
||||
const exportedTypeSuite: t.ITypeSuite = {
|
||||
SnapshotWindow,
|
||||
Product,
|
||||
Features,
|
||||
StripeMetaValues,
|
||||
};
|
||||
export default exportedTypeSuite;
|
@ -1,3 +1,7 @@
|
||||
import Checkers, {Features as FeaturesTi} from './Features-ti';
|
||||
import {CheckerT, createCheckers} from 'ts-interface-checker';
|
||||
import defaultsDeep from 'lodash/defaultsDeep';
|
||||
|
||||
export interface SnapshotWindow {
|
||||
count: number;
|
||||
unit: 'days' | 'month' | 'year';
|
||||
@ -63,6 +67,70 @@ export interface Features {
|
||||
// unbound limit. This is total limit, not per month or per day, it is used as a seed
|
||||
// value for the limits table. To create a per-month limit, there must be a separate
|
||||
// task that resets the usage in the limits table.
|
||||
minimumUnits?: number; // Minimum number of units for the plan. Default no minimum.
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a merged set of features, combining the features of the given objects.
|
||||
* If all objects are null, returns null.
|
||||
*/
|
||||
export function mergedFeatures(...features: (Features|null)[]): Features|null {
|
||||
const filledIn = features.filter(Boolean) as Features[];
|
||||
if (!filledIn.length) { return null; }
|
||||
return filledIn.reduce((acc: Features, f) => defaultsDeep(acc, f), {});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Other meta values stored in Stripe Price or Product metadata.
|
||||
*/
|
||||
export interface StripeMetaValues {
|
||||
isStandard?: boolean;
|
||||
gristProduct?: string;
|
||||
gristLimit?: string;
|
||||
family?: string;
|
||||
trialPeriodDays?: number;
|
||||
}
|
||||
|
||||
export const FeaturesChecker = createCheckers(Checkers).Features as CheckerT<Features>;
|
||||
export const StripeMetaValuesChecker = createCheckers(Checkers).StripeMetaValues as CheckerT<StripeMetaValues>;
|
||||
|
||||
/**
|
||||
* Method takes arbitrary record and returns Features object, trimming any unknown fields.
|
||||
* It mutates the input record.
|
||||
*/
|
||||
export function parseStripeFeatures(record: Record<string, any>): Features {
|
||||
// Stripe metadata can contain many more values that we don't care about, so we just
|
||||
// filter out the ones we do care about.
|
||||
const validProps = new Set(FeaturesTi.props.map(p => p.name));
|
||||
for (const key in record) {
|
||||
|
||||
// If this is unknown property, remove it.
|
||||
if (!validProps.has(key)) {
|
||||
delete record[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = record[key];
|
||||
const tester = FeaturesChecker.getProp(key);
|
||||
// If the top level property is invalid, just remove it.
|
||||
if (!tester.strictTest(value)) {
|
||||
// There is an exception for 1 and 0, if the target type is boolean.
|
||||
switch (value) {
|
||||
case 1:
|
||||
record[key] = true;
|
||||
break;
|
||||
case 0:
|
||||
record[key] = false;
|
||||
break;
|
||||
}
|
||||
// Test one more time, if it is still invalid, remove it.
|
||||
if (!tester.strictTest(record[key])) {
|
||||
delete record[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
// Check whether it is possible to add members at the org level. There's no flag
|
||||
@ -73,22 +141,44 @@ export function canAddOrgMembers(features: Features): boolean {
|
||||
return features.maxWorkspacesPerOrg !== 1;
|
||||
}
|
||||
|
||||
|
||||
export const PERSONAL_LEGACY_PLAN = 'starter';
|
||||
// Grist is aware only about those plans.
|
||||
// Those plans are synchronized with database only if they don't exists currently.
|
||||
export const PERSONAL_FREE_PLAN = 'personalFree';
|
||||
export const TEAM_FREE_PLAN = 'teamFree';
|
||||
|
||||
// This is a plan for suspended users.
|
||||
export const SUSPENDED_PLAN = 'suspended';
|
||||
|
||||
// This is virtual plan for anonymous users.
|
||||
export const ANONYMOUS_PLAN = 'anonymous';
|
||||
// This is free plan. Grist doesn't offer a way to create it using API, but
|
||||
// it can be configured as a substitute for any other plan using environment variables (like DEFAULT_TEAM_PLAN)
|
||||
export const FREE_PLAN = 'Free';
|
||||
|
||||
// This is a plan for temporary org, before assigning a real plan.
|
||||
export const STUB_PLAN = 'stub';
|
||||
|
||||
// Legacy free personal plan, which is not available anymore or created in new instances, but used
|
||||
// here for displaying purposes and in tests.
|
||||
export const PERSONAL_LEGACY_PLAN = 'starter';
|
||||
|
||||
// Pro plan for team sites (first tier). It is generally read from Stripe, but we use it in tests, so
|
||||
// by default all installation have it. When Stripe updates it, it will be synchronized with Grist.
|
||||
export const TEAM_PLAN = 'team';
|
||||
|
||||
|
||||
export const displayPlanName: { [key: string]: string } = {
|
||||
[PERSONAL_LEGACY_PLAN]: 'Free Personal (Legacy)',
|
||||
[PERSONAL_FREE_PLAN]: 'Free Personal',
|
||||
[TEAM_FREE_PLAN]: 'Team Free',
|
||||
[SUSPENDED_PLAN]: 'Suspended',
|
||||
[ANONYMOUS_PLAN]: 'Anonymous',
|
||||
[FREE_PLAN]: 'Free',
|
||||
[TEAM_PLAN]: 'Pro'
|
||||
} as const;
|
||||
|
||||
// Returns true if `planName` is for a personal product.
|
||||
export function isPersonalPlan(planName: string): boolean {
|
||||
return isFreePersonalPlan(planName);
|
||||
// Returns true if `planName` is for a legacy product.
|
||||
export function isLegacyPlan(planName: string): boolean {
|
||||
return planName === PERSONAL_LEGACY_PLAN;
|
||||
}
|
||||
|
||||
// Returns true if `planName` is for a free personal product.
|
||||
@ -96,32 +186,38 @@ export function isFreePersonalPlan(planName: string): boolean {
|
||||
return [PERSONAL_LEGACY_PLAN, PERSONAL_FREE_PLAN].includes(planName);
|
||||
}
|
||||
|
||||
// Returns true if `planName` is for a legacy product.
|
||||
export function isLegacyPlan(planName: string): boolean {
|
||||
return isFreeLegacyPlan(planName);
|
||||
}
|
||||
|
||||
// Returns true if `planName` is for a free legacy product.
|
||||
export function isFreeLegacyPlan(planName: string): boolean {
|
||||
return [PERSONAL_LEGACY_PLAN].includes(planName);
|
||||
}
|
||||
|
||||
// Returns true if `planName` is for a team product.
|
||||
export function isTeamPlan(planName: string): boolean {
|
||||
return !isPersonalPlan(planName);
|
||||
}
|
||||
|
||||
// Returns true if `planName` is for a free team product.
|
||||
export function isFreeTeamPlan(planName: string): boolean {
|
||||
return [TEAM_FREE_PLAN].includes(planName);
|
||||
}
|
||||
|
||||
// Returns true if `planName` is for a free product.
|
||||
/**
|
||||
* Actually all known plans don't require billing (which doesn't mean they are free actually, as it can
|
||||
* be overridden by Stripe). There are also pro (team) and enterprise plans, which are billable, but they are
|
||||
* read from Stripe.
|
||||
*/
|
||||
export function isFreePlan(planName: string): boolean {
|
||||
return (
|
||||
isFreePersonalPlan(planName) ||
|
||||
isFreeTeamPlan(planName) ||
|
||||
isFreeLegacyPlan(planName) ||
|
||||
planName === 'Free'
|
||||
);
|
||||
switch (planName) {
|
||||
case PERSONAL_LEGACY_PLAN:
|
||||
case PERSONAL_FREE_PLAN:
|
||||
case TEAM_FREE_PLAN:
|
||||
case FREE_PLAN:
|
||||
case ANONYMOUS_PLAN:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Are the plan limits managed by Grist.
|
||||
*/
|
||||
export function isManagedPlan(planName: string): boolean {
|
||||
switch (planName) {
|
||||
case PERSONAL_LEGACY_PLAN:
|
||||
case PERSONAL_FREE_PLAN:
|
||||
case TEAM_FREE_PLAN:
|
||||
case FREE_PLAN:
|
||||
case SUSPENDED_PLAN:
|
||||
case ANONYMOUS_PLAN:
|
||||
case STUB_PLAN:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { isTeamPlan, Product } from 'app/common/Features';
|
||||
import { Features } from 'app/common/Features';
|
||||
import { normalizeEmail } from 'app/common/emails';
|
||||
import { PermissionData, PermissionDelta } from 'app/common/UserAPI';
|
||||
|
||||
@ -37,11 +37,10 @@ export interface ShareAnnotatorOptions {
|
||||
* current shares in place.
|
||||
*/
|
||||
export class ShareAnnotator {
|
||||
private _features = this._product?.features ?? {};
|
||||
private _supportEmail = this._options.supportEmail;
|
||||
|
||||
constructor(
|
||||
private _product: Product|null,
|
||||
private _features: Features|null,
|
||||
private _state: PermissionData,
|
||||
private _options: ShareAnnotatorOptions = {}
|
||||
) {
|
||||
@ -52,9 +51,9 @@ export class ShareAnnotator {
|
||||
}
|
||||
|
||||
public annotateChanges(change: PermissionDelta): ShareAnnotations {
|
||||
const features = this._features;
|
||||
const features = this._features ?? {};
|
||||
const annotations: ShareAnnotations = {
|
||||
hasTeam: !this._product || isTeamPlan(this._product.name),
|
||||
hasTeam: !this._features || this._features.vanityDomain,
|
||||
users: new Map(),
|
||||
};
|
||||
if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) {
|
||||
|
@ -9,7 +9,7 @@ import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues,
|
||||
TableRecordValuesWithoutIds, UserAction} from 'app/common/DocActions';
|
||||
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
|
||||
import {OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {Product} from 'app/common/Features';
|
||||
import {Features, Product} from 'app/common/Features';
|
||||
import {isClient} from 'app/common/gristUrls';
|
||||
import {encodeQueryParams} from 'app/common/gutil';
|
||||
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
|
||||
@ -75,8 +75,10 @@ export interface BillingAccount {
|
||||
id: number;
|
||||
individual: boolean;
|
||||
product: Product;
|
||||
stripePlanId: string; // Stripe price id.
|
||||
isManager: boolean;
|
||||
inGoodStanding: boolean;
|
||||
features: Features;
|
||||
externalOptions?: {
|
||||
invoiceId?: string;
|
||||
};
|
||||
|
@ -134,8 +134,9 @@ export interface IGristUrlState {
|
||||
docTour?: boolean;
|
||||
manageUsers?: boolean;
|
||||
createTeam?: boolean;
|
||||
upgradeTeam?: boolean;
|
||||
params?: {
|
||||
billingPlan?: string;
|
||||
billingPlan?: string; // priceId
|
||||
planType?: string;
|
||||
billingTask?: BillingTask;
|
||||
embed?: boolean;
|
||||
@ -358,6 +359,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
||||
url.hash = 'manage-users';
|
||||
} else if (state.createTeam) {
|
||||
url.hash = 'create-team';
|
||||
} else if (state.upgradeTeam) {
|
||||
url.hash = 'upgrade-team';
|
||||
} else {
|
||||
url.hash = '';
|
||||
}
|
||||
@ -573,6 +576,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
|
||||
state.docTour = hashMap.get('#') === 'repeat-doc-tour';
|
||||
state.manageUsers = hashMap.get('#') === 'manage-users';
|
||||
state.createTeam = hashMap.get('#') === 'create-team';
|
||||
state.upgradeTeam = hashMap.get('#') === 'upgrade-team';
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
@ -72,7 +72,7 @@ export function addOrg(
|
||||
userId: number,
|
||||
props: Partial<OrganizationProperties>,
|
||||
options?: {
|
||||
planType?: string,
|
||||
product?: string,
|
||||
billing?: BillingOptions,
|
||||
}
|
||||
): Promise<number> {
|
||||
|
@ -4,6 +4,7 @@ import {Organization} from 'app/gen-server/entity/Organization';
|
||||
import {Product} from 'app/gen-server/entity/Product';
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import {Limit} from 'app/gen-server/entity/Limit';
|
||||
import {Features, mergedFeatures} from 'app/common/Features';
|
||||
|
||||
// This type is for billing account status information. Intended for stuff
|
||||
// like "free trial running out in N days".
|
||||
@ -35,6 +36,9 @@ export class BillingAccount extends BaseEntity {
|
||||
@JoinColumn({name: 'product_id'})
|
||||
public product: Product;
|
||||
|
||||
@Column({type: nativeValues.jsonEntityType, nullable: true})
|
||||
public features: Features|null;
|
||||
|
||||
@Column({type: Boolean})
|
||||
public individual: boolean;
|
||||
|
||||
@ -57,6 +61,9 @@ export class BillingAccount extends BaseEntity {
|
||||
@Column({name: 'stripe_plan_id', type: String, nullable: true})
|
||||
public stripePlanId: string | null;
|
||||
|
||||
@Column({name: 'payment_link', type: String, nullable: true})
|
||||
public paymentLink: string | null;
|
||||
|
||||
@Column({name: 'external_id', type: String, nullable: true})
|
||||
public externalId: string | null;
|
||||
|
||||
@ -66,6 +73,7 @@ export class BillingAccount extends BaseEntity {
|
||||
@OneToMany(type => BillingAccountManager, manager => manager.billingAccount)
|
||||
public managers: BillingAccountManager[];
|
||||
|
||||
// Only one billing account per organization.
|
||||
@OneToMany(type => Organization, org => org.billingAccount)
|
||||
public orgs: Organization[];
|
||||
|
||||
@ -79,4 +87,8 @@ export class BillingAccount extends BaseEntity {
|
||||
// A calculated column summarizing whether active user is a manager of the billing account.
|
||||
// (No @Column needed since calculation is done in javascript not sql)
|
||||
public isManager?: boolean;
|
||||
|
||||
public getFeatures(): Features {
|
||||
return mergedFeatures(this.features, this.product.features) ?? {};
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,11 @@
|
||||
import {Features, Product as IProduct, PERSONAL_FREE_PLAN, PERSONAL_LEGACY_PLAN, TEAM_FREE_PLAN,
|
||||
import {Features, FREE_PLAN,
|
||||
Product as IProduct,
|
||||
isManagedPlan,
|
||||
PERSONAL_FREE_PLAN,
|
||||
PERSONAL_LEGACY_PLAN,
|
||||
STUB_PLAN,
|
||||
SUSPENDED_PLAN,
|
||||
TEAM_FREE_PLAN,
|
||||
TEAM_PLAN} from 'app/common/Features';
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import * as assert from 'assert';
|
||||
@ -21,7 +28,8 @@ export const personalLegacyFeatures: Features = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A summary of features used in 'team' plans.
|
||||
* A summary of features used in 'team' plans. Grist ensures that this plan exists in the database, but it
|
||||
* is treated as an external plan that came from Stripe, and is not modified by Grist.
|
||||
*/
|
||||
export const teamFeatures: Features = {
|
||||
workspaces: true,
|
||||
@ -71,16 +79,11 @@ export const teamFreeFeatures: Features = {
|
||||
baseMaxAssistantCalls: 100,
|
||||
};
|
||||
|
||||
export const testDailyApiLimitFeatures = {
|
||||
...teamFreeFeatures,
|
||||
baseMaxApiUnitsPerDocumentPerDay: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* A summary of features used in unrestricted grandfathered accounts, and also
|
||||
* in some test settings.
|
||||
*/
|
||||
export const grandfatherFeatures: Features = {
|
||||
export const freeAllFeatures: Features = {
|
||||
workspaces: true,
|
||||
vanityDomain: true,
|
||||
};
|
||||
@ -98,61 +101,44 @@ export const suspendedFeatures: Features = {
|
||||
|
||||
/**
|
||||
*
|
||||
* Products are a bundle of enabled features. Most products in
|
||||
* Grist correspond to products in stripe. The correspondence is
|
||||
* established by a gristProduct metadata field on stripe plans.
|
||||
*
|
||||
* In addition, there are the following products in Grist that don't
|
||||
* exist in stripe:
|
||||
* - The product named 'Free'. This is a product used for organizations
|
||||
* created prior to the billing system being set up.
|
||||
* - The product named 'stub'. This is product assigned to new
|
||||
* organizations that should not be usable until a paid plan
|
||||
* is set up for them.
|
||||
*
|
||||
* TODO: change capitalization of name of grandfather product.
|
||||
*
|
||||
* Products are a bundle of enabled features. Grist knows only
|
||||
* about free products and creates them by default. Other products
|
||||
* are created by the billing system (Stripe) and synchronized when used
|
||||
* or via webhooks.
|
||||
*/
|
||||
export const PRODUCTS: IProduct[] = [
|
||||
// This is a product for grandfathered accounts/orgs.
|
||||
{
|
||||
name: 'Free',
|
||||
features: grandfatherFeatures,
|
||||
},
|
||||
|
||||
// This is a product for newly created accounts/orgs.
|
||||
{
|
||||
name: 'stub',
|
||||
features: {},
|
||||
},
|
||||
// This is a product for legacy personal accounts/orgs.
|
||||
{
|
||||
name: PERSONAL_LEGACY_PLAN,
|
||||
features: personalLegacyFeatures,
|
||||
},
|
||||
{
|
||||
name: 'professional', // deprecated, can be removed once no longer referred to in stripe.
|
||||
features: teamFeatures,
|
||||
name: PERSONAL_FREE_PLAN,
|
||||
features: personalFreeFeatures, // those features are read from database, here are only as a reference.
|
||||
},
|
||||
{
|
||||
name: TEAM_FREE_PLAN,
|
||||
features: teamFreeFeatures,
|
||||
},
|
||||
// This is a product for a team site (used in tests mostly, as the real team plan is managed by Stripe).
|
||||
{
|
||||
name: TEAM_PLAN,
|
||||
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
|
||||
name: SUSPENDED_PLAN,
|
||||
features: suspendedFeatures,
|
||||
},
|
||||
{
|
||||
name: TEAM_FREE_PLAN,
|
||||
features: teamFreeFeatures
|
||||
name: FREE_PLAN,
|
||||
features: freeAllFeatures,
|
||||
},
|
||||
// This is a product for newly created accounts/orgs.
|
||||
{
|
||||
name: PERSONAL_FREE_PLAN,
|
||||
features: personalFreeFeatures,
|
||||
},
|
||||
name: STUB_PLAN,
|
||||
features: {},
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -161,7 +147,6 @@ export const PRODUCTS: IProduct[] = [
|
||||
*/
|
||||
export function getDefaultProductNames() {
|
||||
const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;
|
||||
// TODO: can be removed once new deal is released.
|
||||
const personalFreePlan = PERSONAL_FREE_PLAN;
|
||||
return {
|
||||
// Personal site start off on a functional plan.
|
||||
@ -218,6 +203,12 @@ export async function synchronizeProducts(
|
||||
.map(p => [p.name, p]));
|
||||
for (const product of desiredProducts.values()) {
|
||||
if (existingProducts.has(product.name)) {
|
||||
|
||||
// Synchronize features only of known plans (team plan is not known).
|
||||
if (!isManagedPlan(product.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const p = existingProducts.get(product.name)!;
|
||||
try {
|
||||
assert.deepStrictEqual(p.features, product.features);
|
||||
|
@ -4,7 +4,7 @@ import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
||||
import {getDataLimitStatus} from 'app/common/DocLimits';
|
||||
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
|
||||
import {normalizeEmail} from 'app/common/emails';
|
||||
import {canAddOrgMembers, Features} from 'app/common/Features';
|
||||
import {ANONYMOUS_PLAN, canAddOrgMembers, Features, PERSONAL_FREE_PLAN} from 'app/common/Features';
|
||||
import {buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId} from 'app/common/gristUrls';
|
||||
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
|
||||
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||
@ -72,6 +72,7 @@ import {
|
||||
import uuidv4 from "uuid/v4";
|
||||
import flatten = require('lodash/flatten');
|
||||
import pick = require('lodash/pick');
|
||||
import defaultsDeep = require('lodash/defaultsDeep');
|
||||
|
||||
// Support transactions in Sqlite in async code. This is a monkey patch, affecting
|
||||
// the prototypes of various TypeORM classes.
|
||||
@ -264,16 +265,18 @@ interface CreateWorkspaceOptions {
|
||||
|
||||
/**
|
||||
* Available options for creating a new org with a new billing account.
|
||||
* It serves only as a way to remove all foreign keys from the entity.
|
||||
*/
|
||||
export type BillingOptions = Partial<Pick<BillingAccount,
|
||||
'product' |
|
||||
'stripeCustomerId' |
|
||||
'stripeSubscriptionId' |
|
||||
'stripePlanId' |
|
||||
'externalId' |
|
||||
'externalOptions' |
|
||||
'inGoodStanding' |
|
||||
'status'
|
||||
'status' |
|
||||
'paymentLink' |
|
||||
'features'
|
||||
>>;
|
||||
|
||||
/**
|
||||
@ -748,7 +751,8 @@ export class HomeDBManager extends EventEmitter {
|
||||
// get a bit confusing.
|
||||
const result = await this.addOrg(user, {name: "Personal"}, {
|
||||
setUserAsOwner: true,
|
||||
useNewPlan: true
|
||||
useNewPlan: true,
|
||||
product: PERSONAL_FREE_PLAN,
|
||||
}, manager);
|
||||
if (result.status !== 200) {
|
||||
throw new Error(result.errMessage);
|
||||
@ -808,22 +812,17 @@ export class HomeDBManager extends EventEmitter {
|
||||
* and orgs.acl_rules.group.memberUsers should be included.
|
||||
*/
|
||||
public async getOrgMemberCount(org: string|number|Organization): Promise<number> {
|
||||
if (!(org instanceof Organization)) {
|
||||
const orgQuery = this._org(null, false, org, {
|
||||
needRealOrg: true
|
||||
})
|
||||
// Join the org's ACL rules (with 1st level groups/users listed).
|
||||
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
||||
.leftJoinAndSelect('acl_rules.group', 'org_groups')
|
||||
.leftJoinAndSelect('org_groups.memberUsers', 'org_member_users');
|
||||
const result = await orgQuery.getRawAndEntities();
|
||||
if (result.entities.length === 0) {
|
||||
// If the query for the org failed, return the failure result.
|
||||
throw new ApiError('org not found', 404);
|
||||
}
|
||||
org = result.entities[0];
|
||||
}
|
||||
return getResourceUsers(org, this.defaultNonGuestGroupNames).length;
|
||||
return (await this._getOrgMembers(org)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of billable users in the given org.
|
||||
*/
|
||||
public async getOrgBillableMemberCount(org: string|number|Organization): Promise<number> {
|
||||
return (await this._getOrgMembers(org))
|
||||
.filter(u => !u.options?.isConsultant) // remove consultants.
|
||||
.filter(u => !this.getExcludedUserIds().includes(u.id)) // remove support user and other
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -892,11 +891,13 @@ export class HomeDBManager extends EventEmitter {
|
||||
id: 0,
|
||||
individual: true,
|
||||
product: {
|
||||
name: 'anonymous',
|
||||
name: ANONYMOUS_PLAN,
|
||||
features: personalFreeFeatures,
|
||||
},
|
||||
stripePlanId: '',
|
||||
isManager: false,
|
||||
inGoodStanding: true,
|
||||
features: {},
|
||||
},
|
||||
host: null
|
||||
};
|
||||
@ -1080,7 +1081,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
orgQuery = this._addFeatures(orgQuery);
|
||||
const orgQueryResult = await verifyEntity(orgQuery);
|
||||
const org: Organization = this.unwrapQueryResult(orgQueryResult);
|
||||
const productFeatures = org.billingAccount.product.features;
|
||||
const productFeatures = org.billingAccount.getFeatures();
|
||||
|
||||
// Grab all the non-removed documents in the org.
|
||||
let docsQuery = this._docs()
|
||||
@ -1273,7 +1274,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
if (docs.length === 0) { throw new ApiError('document not found', 404); }
|
||||
if (docs.length > 1) { throw new ApiError('ambiguous document request', 400); }
|
||||
doc = docs[0];
|
||||
const features = doc.workspace.org.billingAccount.product.features;
|
||||
const features = doc.workspace.org.billingAccount.getFeatures();
|
||||
if (features.readOnlyDocs || this._restrictedMode) {
|
||||
// Don't allow any access to docs that is stronger than "viewers".
|
||||
doc.access = roles.getWeakestRole('viewers', doc.access);
|
||||
@ -1399,14 +1400,14 @@ export class HomeDBManager extends EventEmitter {
|
||||
* 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
|
||||
* @param product: if set, controls the type of plan used for the org. Only
|
||||
* meaningful for team sites currently.
|
||||
* @param billing: if set, controls the billing account settings for the org.
|
||||
*/
|
||||
public async addOrg(user: User, props: Partial<OrganizationProperties>,
|
||||
options: { setUserAsOwner: boolean,
|
||||
useNewPlan: boolean,
|
||||
planType?: string,
|
||||
product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable.
|
||||
billing?: BillingOptions},
|
||||
transaction?: EntityManager): Promise<QueryResult<number>> {
|
||||
const notifications: Array<() => void> = [];
|
||||
@ -1434,20 +1435,21 @@ export class HomeDBManager extends EventEmitter {
|
||||
let billingAccount;
|
||||
if (options.useNewPlan) { // use separate billing account (currently yes)
|
||||
const productNames = getDefaultProductNames();
|
||||
let productName = options.setUserAsOwner ? productNames.personal :
|
||||
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 !== 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;
|
||||
}
|
||||
const product =
|
||||
// For personal site use personal product always (ignoring options.product)
|
||||
options.setUserAsOwner ? productNames.personal :
|
||||
// For team site use the product from options if given
|
||||
options.product ? options.product :
|
||||
// If we are support user, use team product
|
||||
// A bit fragile: this is called during creation of support@ user, before
|
||||
// getSupportUserId() is available, but with setUserAsOwner of true.
|
||||
user.id === this.getSupportUserId() ? productNames.team :
|
||||
// Otherwise use teamInitial product (a stub).
|
||||
productNames.teamInitial;
|
||||
|
||||
billingAccount = new BillingAccount();
|
||||
billingAccount.individual = options.setUserAsOwner;
|
||||
const dbProduct = await manager.findOne(Product, {where: {name: productName}});
|
||||
const dbProduct = await manager.findOne(Product, {where: {name: product}});
|
||||
if (!dbProduct) {
|
||||
throw new Error('Cannot find product for new organization');
|
||||
}
|
||||
@ -1460,16 +1462,21 @@ export class HomeDBManager extends EventEmitter {
|
||||
// Apply billing settings if requested, but not all of them.
|
||||
if (options.billing) {
|
||||
const billing = options.billing;
|
||||
// If we have features but it is empty object, just remove it
|
||||
if (billing.features && typeof billing.features === 'object' && Object.keys(billing.features).length === 0) {
|
||||
delete billing.features;
|
||||
}
|
||||
const allowedKeys: Array<keyof BillingOptions> = [
|
||||
'product',
|
||||
'stripeCustomerId',
|
||||
'stripeSubscriptionId',
|
||||
'stripePlanId',
|
||||
'features',
|
||||
// save will fail if externalId is a duplicate.
|
||||
'externalId',
|
||||
'externalOptions',
|
||||
'inGoodStanding',
|
||||
'status'
|
||||
'status',
|
||||
'paymentLink'
|
||||
];
|
||||
Object.keys(billing).forEach(key => {
|
||||
if (!allowedKeys.includes(key as any)) {
|
||||
@ -1721,7 +1728,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
return queryResult;
|
||||
}
|
||||
const org: Organization = queryResult.data;
|
||||
const features = org.billingAccount.product.features;
|
||||
const features = org.billingAccount.getFeatures();
|
||||
if (features.maxWorkspacesPerOrg !== undefined) {
|
||||
// we need to count how many workspaces are in the current org, and if we
|
||||
// are already at or above the limit, then fail.
|
||||
@ -2131,7 +2138,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
// of other information.
|
||||
const updated = pick(billingAccountCopy, 'inGoodStanding', 'status', 'stripeCustomerId',
|
||||
'stripeSubscriptionId', 'stripePlanId', 'product', 'externalId',
|
||||
'externalOptions');
|
||||
'externalOptions', 'paymentLink');
|
||||
billingAccount.paid = undefined; // workaround for a typeorm bug fixed upstream in
|
||||
// https://github.com/typeorm/typeorm/pull/4035
|
||||
await transaction.save(Object.assign(billingAccount, updated));
|
||||
@ -2313,7 +2320,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
await this._updateUserPermissions(groups, userIdDelta, manager);
|
||||
this._checkUserChangeAllowed(userId, groups);
|
||||
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
|
||||
const features = ws.org.billingAccount.product.features;
|
||||
const features = ws.org.billingAccount.getFeatures();
|
||||
const limit = features.maxSharesPerWorkspace;
|
||||
if (limit !== undefined) {
|
||||
this._restrictShares(null, limit, removeRole(nonOrgMembersBefore),
|
||||
@ -2367,7 +2374,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
await this._updateUserPermissions(groups, userIdDelta, manager);
|
||||
this._checkUserChangeAllowed(userId, groups);
|
||||
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
|
||||
const features = org.billingAccount.product.features;
|
||||
const features = org.billingAccount.getFeatures();
|
||||
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter);
|
||||
}
|
||||
await manager.save(groups);
|
||||
@ -2629,7 +2636,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
const destOrgGroups = getNonGuestGroups(destOrg);
|
||||
const nonOrgMembersBefore = this._getUserDifference(docGroups, sourceOrgGroups);
|
||||
const nonOrgMembersAfter = this._getUserDifference(docGroups, destOrgGroups);
|
||||
const features = destOrg.billingAccount.product.features;
|
||||
const features = destOrg.billingAccount.getFeatures();
|
||||
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter, false);
|
||||
}
|
||||
}
|
||||
@ -2768,6 +2775,32 @@ export class HomeDBManager extends EventEmitter {
|
||||
.execute();
|
||||
}
|
||||
|
||||
public async getProduct(name: string): Promise<Product | undefined> {
|
||||
return await this._connection.createQueryBuilder()
|
||||
.select('product')
|
||||
.from(Product, 'product')
|
||||
.where('name = :name', {name})
|
||||
.getOne() || undefined;
|
||||
}
|
||||
|
||||
public async getDocFeatures(docId: string): Promise<Features | undefined> {
|
||||
const billingAccount = await this._connection.createQueryBuilder()
|
||||
.select('account')
|
||||
.from(BillingAccount, 'account')
|
||||
.leftJoinAndSelect('account.product', 'product')
|
||||
.leftJoinAndSelect('account.orgs', 'org')
|
||||
.leftJoinAndSelect('org.workspaces', 'workspace')
|
||||
.leftJoinAndSelect('workspace.docs', 'doc')
|
||||
.where('doc.id = :docId', {docId})
|
||||
.getOne() || undefined;
|
||||
|
||||
if (!billingAccount) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return defaultsDeep(billingAccount.features, billingAccount.product.features);
|
||||
}
|
||||
|
||||
public async getDocProduct(docId: string): Promise<Product | undefined> {
|
||||
return await this._connection.createQueryBuilder()
|
||||
.select('product')
|
||||
@ -3011,11 +3044,11 @@ export class HomeDBManager extends EventEmitter {
|
||||
}
|
||||
let existing = org?.billingAccount?.limits?.[0];
|
||||
if (!existing) {
|
||||
const product = org?.billingAccount?.product;
|
||||
if (!product) {
|
||||
const features = org?.billingAccount?.getFeatures();
|
||||
if (!features) {
|
||||
throw new ApiError(`getLimit: no product found for org`, 500);
|
||||
}
|
||||
if (product.features.baseMaxAssistantCalls === undefined) {
|
||||
if (features.baseMaxAssistantCalls === undefined) {
|
||||
// If the product has no assistantLimit, then it is not billable yet, and we don't need to
|
||||
// track usage as it is basically unlimited.
|
||||
return null;
|
||||
@ -3023,7 +3056,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
existing = new Limit();
|
||||
existing.billingAccountId = org.billingAccountId;
|
||||
existing.type = limitType;
|
||||
existing.limit = product.features.baseMaxAssistantCalls ?? 0;
|
||||
existing.limit = features.baseMaxAssistantCalls ?? 0;
|
||||
existing.usage = 0;
|
||||
}
|
||||
const limitLess = existing.limit === -1; // -1 means no limit, it is not possible to do in stripe.
|
||||
@ -3112,6 +3145,25 @@ export class HomeDBManager extends EventEmitter {
|
||||
.getOne();
|
||||
}
|
||||
|
||||
private async _getOrgMembers(org: string|number|Organization) {
|
||||
if (!(org instanceof Organization)) {
|
||||
const orgQuery = this._org(null, false, org, {
|
||||
needRealOrg: true
|
||||
})
|
||||
// Join the org's ACL rules (with 1st level groups/users listed).
|
||||
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
||||
.leftJoinAndSelect('acl_rules.group', 'org_groups')
|
||||
.leftJoinAndSelect('org_groups.memberUsers', 'org_member_users');
|
||||
const result = await orgQuery.getRawAndEntities();
|
||||
if (result.entities.length === 0) {
|
||||
// If the query for the org failed, return the failure result.
|
||||
throw new ApiError('org not found', 404);
|
||||
}
|
||||
org = result.entities[0];
|
||||
}
|
||||
return getResourceUsers(org, this.defaultNonGuestGroupNames);
|
||||
}
|
||||
|
||||
private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise<Limit|null> {
|
||||
if (accountId === 0) {
|
||||
throw new Error(`getLimit: called for not existing account`);
|
||||
@ -4196,7 +4248,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
if (value.billingAccount) {
|
||||
// This is an organization with billing account information available. Check limits.
|
||||
const org = value as Organization;
|
||||
const features = org.billingAccount.product.features;
|
||||
const features = org.billingAccount.getFeatures();
|
||||
if (!features.vanityDomain) {
|
||||
// Vanity domain not allowed for this org.
|
||||
options = {...options, suppressDomain: true};
|
||||
@ -4625,7 +4677,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
|
||||
// Throw an error if there's no room for adding another document.
|
||||
private async _checkRoomForAnotherDoc(workspace: Workspace, manager: EntityManager) {
|
||||
const features = workspace.org.billingAccount.product.features;
|
||||
const features = workspace.org.billingAccount.getFeatures();
|
||||
if (features.maxDocsPerOrg !== undefined) {
|
||||
// we need to count how many docs are in the current org, and if we
|
||||
// are already at or above the limit, then fail.
|
||||
|
23
app/gen-server/migration/1711557445716-Billing.ts
Normal file
23
app/gen-server/migration/1711557445716-Billing.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {nativeValues} from 'app/gen-server/lib/values';
|
||||
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
|
||||
|
||||
export class Billing1711557445716 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.addColumn('billing_accounts', new TableColumn({
|
||||
name: 'features',
|
||||
type: nativeValues.jsonType,
|
||||
isNullable: true,
|
||||
}));
|
||||
|
||||
await queryRunner.addColumn('billing_accounts', new TableColumn({
|
||||
name: 'payment_link',
|
||||
type: nativeValues.jsonType,
|
||||
isNullable: true,
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn('billing_accounts', 'features');
|
||||
await queryRunner.dropColumn('billing_accounts', 'payment_link');
|
||||
}
|
||||
}
|
@ -176,7 +176,7 @@ export function addSiteCommand(program: commander.Command,
|
||||
}, {
|
||||
setUserAsOwner: false,
|
||||
useNewPlan: true,
|
||||
planType: 'teamFree'
|
||||
product: 'teamFree'
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
@ -1893,7 +1893,7 @@ export class DocWorkerApi {
|
||||
// or to be wrongly rejected after upgrading.
|
||||
const doc = (req as RequestWithLogin).docAuth!.cachedDoc!;
|
||||
|
||||
const max = doc.workspace.org.billingAccount?.product.features.baseMaxApiUnitsPerDocumentPerDay;
|
||||
const max = doc.workspace.org.billingAccount?.getFeatures().baseMaxApiUnitsPerDocumentPerDay;
|
||||
if (!max) {
|
||||
// This doc has no associated product (happens to new unsaved docs)
|
||||
// or the product has no API limit. Allow the request through.
|
||||
|
@ -1092,7 +1092,7 @@ export class FlexServer implements GristServer {
|
||||
// If "welcomeNewUser" is ever added to billing pages, we'd need
|
||||
// to avoid a redirect loop.
|
||||
|
||||
if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.product.features.vanityDomain) {
|
||||
if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.getFeatures().vanityDomain) {
|
||||
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : '';
|
||||
return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`);
|
||||
}
|
||||
|
@ -175,8 +175,8 @@ export class HostedStorageManager implements IDocStorageManager {
|
||||
return path.join(dir, 'meta.json');
|
||||
},
|
||||
async docId => {
|
||||
const product = await dbManager.getDocProduct(docId);
|
||||
return product?.features.snapshotWindow;
|
||||
const features = await dbManager.getDocFeatures(docId);
|
||||
return features?.snapshotWindow;
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -22,7 +22,7 @@ export const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ?
|
||||
// Database fields that we permit in entities but don't want to cross the api.
|
||||
const INTERNAL_FIELDS = new Set([
|
||||
'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId',
|
||||
'stripeSubscriptionId', 'stripePlanId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
|
||||
'stripeSubscriptionId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
|
||||
'authSubject', 'usage', 'createdBy'
|
||||
]);
|
||||
|
||||
|
@ -90,7 +90,7 @@ async function setupDb() {
|
||||
}, {
|
||||
setUserAsOwner: false,
|
||||
useNewPlan: true,
|
||||
planType: TEAM_FREE_PLAN
|
||||
product: TEAM_FREE_PLAN
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import {Organization} from 'app/gen-server/entity/Organization';
|
||||
import {Product} from 'app/gen-server/entity/Product';
|
||||
import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager';
|
||||
import {TestServer} from 'test/gen-server/apiUtils';
|
||||
import {TEAM_FREE_PLAN} from 'app/common/Features';
|
||||
|
||||
const assert = chai.assert;
|
||||
|
||||
@ -920,6 +921,9 @@ describe('ApiServer', function() {
|
||||
status: null,
|
||||
externalId: null,
|
||||
externalOptions: null,
|
||||
features: null,
|
||||
stripePlanId: null,
|
||||
paymentLink: null,
|
||||
},
|
||||
});
|
||||
assert.isNotNull(org.updatedAt);
|
||||
@ -2151,7 +2155,7 @@ describe('ApiServer', function() {
|
||||
'best-friends-squad', false);
|
||||
await dbManager.connection.query(
|
||||
'update billing_accounts set product_id = (select id from products where name = $1) where id = $2',
|
||||
['teamFree', prevAccount.id]
|
||||
[TEAM_FREE_PLAN, prevAccount.id]
|
||||
);
|
||||
|
||||
const resp = await axios.post(`${homeUrl}/api/orgs/${freeTeamOrgId}/workspaces`, {
|
||||
|
@ -64,8 +64,8 @@ describe('ApiSession', function() {
|
||||
'createdAt', 'updatedAt', 'host']);
|
||||
assert.deepEqual(resp.data.org.billingAccount,
|
||||
{ id: 1, individual: false, inGoodStanding: true, status: null,
|
||||
externalId: null, externalOptions: null,
|
||||
isManager: true, paid: false,
|
||||
externalId: null, externalOptions: null, paymentLink: null,
|
||||
isManager: true, paid: false, features: null, stripePlanId: null,
|
||||
product: { id: 1, name: 'Free', features: {workspaces: true, vanityDomain: true} } });
|
||||
|
||||
// Check that internally we have access to stripe ids.
|
||||
@ -74,7 +74,7 @@ describe('ApiSession', function() {
|
||||
assert.hasAllKeys(org2.data!.billingAccount,
|
||||
['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId',
|
||||
'stripeSubscriptionId', 'stripePlanId', 'product', 'paid', 'isManager',
|
||||
'externalId', 'externalOptions']);
|
||||
'externalId', 'externalOptions', 'features', 'paymentLink']);
|
||||
});
|
||||
|
||||
it('GET /api/session/access/active returns orgErr when org is forbidden', async function() {
|
||||
|
@ -41,6 +41,7 @@ import {ForkIndexes1678737195050 as ForkIndexes} from 'app/gen-server/migration/
|
||||
import {ActivationPrefs1682636695021 as ActivationPrefs} from 'app/gen-server/migration/1682636695021-ActivationPrefs';
|
||||
import {AssistantLimit1685343047786 as AssistantLimit} from 'app/gen-server/migration/1685343047786-AssistantLimit';
|
||||
import {Shares1701557445716 as Shares} from 'app/gen-server/migration/1701557445716-Shares';
|
||||
import {Billing1711557445716 as BillingFeatures} from 'app/gen-server/migration/1711557445716-Billing';
|
||||
|
||||
const home: HomeDBManager = new HomeDBManager();
|
||||
|
||||
@ -49,7 +50,7 @@ const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayE
|
||||
CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs,
|
||||
ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart,
|
||||
DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID,
|
||||
Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares];
|
||||
Forks, ForkIndexes, ActivationPrefs, AssistantLimit, Shares, BillingFeatures];
|
||||
|
||||
// Assert that the "members" acl rule and group exist (or not).
|
||||
function assertMembersGroup(org: Organization, exists: boolean) {
|
||||
|
@ -37,7 +37,7 @@ import {Document} from "app/gen-server/entity/Document";
|
||||
import {Group} from "app/gen-server/entity/Group";
|
||||
import {Login} from "app/gen-server/entity/Login";
|
||||
import {Organization} from "app/gen-server/entity/Organization";
|
||||
import {Product, PRODUCTS, synchronizeProducts, testDailyApiLimitFeatures} from "app/gen-server/entity/Product";
|
||||
import {Product, PRODUCTS, synchronizeProducts, teamFreeFeatures} from "app/gen-server/entity/Product";
|
||||
import {User} from "app/gen-server/entity/User";
|
||||
import {Workspace} from "app/gen-server/entity/Workspace";
|
||||
import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/HomeDBManager';
|
||||
@ -48,6 +48,13 @@ import * as fse from 'fs-extra';
|
||||
|
||||
const ACCESS_GROUPS = ['owners', 'editors', 'viewers', 'guests', 'members'];
|
||||
|
||||
|
||||
export const testDailyApiLimitFeatures = {
|
||||
...teamFreeFeatures,
|
||||
baseMaxApiUnitsPerDocumentPerDay: 3,
|
||||
};
|
||||
|
||||
|
||||
const testProducts = [
|
||||
...PRODUCTS,
|
||||
{
|
||||
@ -428,7 +435,11 @@ class Seed {
|
||||
const ba = new BillingAccount();
|
||||
ba.individual = false;
|
||||
const productName = org.product || 'Free';
|
||||
ba.product = (await Product.findOne({where: {name: productName}}))!;
|
||||
const product = await Product.findOne({where: {name: productName}});
|
||||
if (!product) {
|
||||
throw new Error(`Product not found: ${productName}`);
|
||||
}
|
||||
ba.product = product;
|
||||
o.billingAccount = ba;
|
||||
if (org.domain) { o.domain = org.domain; }
|
||||
if (org.host) { o.host = org.host; }
|
||||
|
@ -236,7 +236,7 @@ export async function getDocWorkerUrl(): Promise<string> {
|
||||
}
|
||||
|
||||
export async function waitForUrl(pattern: RegExp|string, waitMs: number = 2000) {
|
||||
await driver.wait(() => testCurrentUrl(pattern), waitMs);
|
||||
await driver.wait(() => testCurrentUrl(pattern), waitMs, `waiting for url ${pattern}`);
|
||||
}
|
||||
|
||||
|
||||
@ -1823,6 +1823,34 @@ export async function editOrgAcls(): Promise<void> {
|
||||
await driver.findWait('.test-um-members', 3000);
|
||||
}
|
||||
|
||||
export async function addUser(email: string|string[], role?: 'Owner'|'Viewer'|'Editor'): Promise<void> {
|
||||
await driver.findWait('.test-user-icon', 5000).click();
|
||||
await driver.find('.test-dm-org-access').click();
|
||||
await driver.findWait('.test-um-members', 500);
|
||||
const orgInput = await driver.find('.test-um-member-new input');
|
||||
|
||||
const emails = Array.isArray(email) ? email : [email];
|
||||
for(const e of emails) {
|
||||
await orgInput.sendKeys(e, Key.ENTER);
|
||||
if (role && role !== 'Viewer') {
|
||||
await driver.findContentWait('.test-um-member', e, 1000).find('.test-um-member-role').click();
|
||||
await driver.findContent('.test-um-role-option', role ?? 'Viewer').click();
|
||||
}
|
||||
}
|
||||
await driver.find('.test-um-confirm').click();
|
||||
await driver.wait(async () => !await driver.find('.test-um-members').isPresent(), 500);
|
||||
}
|
||||
|
||||
export async function removeUser(email: string): Promise<void> {
|
||||
await driver.findWait('.test-user-icon', 5000).click();
|
||||
await driver.find('.test-dm-org-access').click();
|
||||
await driver.findWait('.test-um-members', 500);
|
||||
const kiwiRow = await driver.findContent('.test-um-member', email);
|
||||
await kiwiRow.find('.test-um-member-delete').click();
|
||||
await driver.find('.test-um-confirm').click();
|
||||
await driver.wait(async () => !await driver.find('.test-um-members').isPresent(), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click confirm on a user manager dialog. If clickRemove is set, then
|
||||
* any extra modal that pops up will be accepted. Returns true unless
|
||||
@ -3746,6 +3774,23 @@ export function findValue(selector: string, value: string|RegExp) {
|
||||
return new WebElementPromise(driver, inner());
|
||||
}
|
||||
|
||||
export async function switchUser(email: string) {
|
||||
await driver.findWait('.test-user-icon', 1000).click();
|
||||
await driver.findContentWait('.test-usermenu-other-email', exactMatch(email), 1000).click();
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the toast message with the given text to appear.
|
||||
*/
|
||||
export async function waitForAccessDenied() {
|
||||
await waitToPass(async () => {
|
||||
assert.equal(
|
||||
await driver.findWait('.test-notifier-toast-message', 1000).getText(),
|
||||
'access denied');
|
||||
});
|
||||
}
|
||||
|
||||
} // end of namespace gristUtils
|
||||
|
||||
stackWrapOwnMethods(gristUtils);
|
||||
|
@ -5,7 +5,6 @@ import {SHARE_KEY_PREFIX} from 'app/common/gristUrls';
|
||||
import {arrayRepeat} from 'app/common/gutil';
|
||||
import {WebhookSummary} from 'app/common/Triggers';
|
||||
import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {testDailyApiLimitFeatures} from 'app/gen-server/entity/Product';
|
||||
import {AddOrUpdateRecord, Record as ApiRecord, ColumnsPut, RecordWithStringId} from 'app/plugin/DocApiTypes';
|
||||
import {CellValue, GristObjCode} from 'app/plugin/GristData';
|
||||
import {
|
||||
@ -41,6 +40,7 @@ import {waitForIt} from 'test/server/wait';
|
||||
import defaultsDeep = require('lodash/defaultsDeep');
|
||||
import pick = require('lodash/pick');
|
||||
import { getDatabase } from 'test/testUtils';
|
||||
import {testDailyApiLimitFeatures} from 'test/gen-server/seed';
|
||||
|
||||
const chimpy = configForUser('Chimpy');
|
||||
const kiwi = configForUser('Kiwi');
|
||||
|
Loading…
Reference in New Issue
Block a user