(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:
Jarosław Sadziński 2024-05-17 21:14:34 +02:00
parent ed9514bae0
commit 60423edc17
40 changed files with 720 additions and 248 deletions

View File

@ -35,6 +35,7 @@ export class DocumentUsage extends Disposable {
private readonly _currentDocUsage = this._docPageModel.currentDocUsage; private readonly _currentDocUsage = this._docPageModel.currentDocUsage;
private readonly _currentOrg = this._docPageModel.currentOrg; private readonly _currentOrg = this._docPageModel.currentOrg;
private readonly _currentProduct = this._docPageModel.currentProduct; private readonly _currentProduct = this._docPageModel.currentProduct;
private readonly _currentFeatures = this._docPageModel.currentFeatures;
// TODO: Update this whenever the rest of the UI is internationalized. // TODO: Update this whenever the rest of the UI is internationalized.
private readonly _rowCountFormatter = new Intl.NumberFormat('en-US'); private readonly _rowCountFormatter = new Intl.NumberFormat('en-US');
@ -56,8 +57,8 @@ export class DocumentUsage extends Disposable {
}); });
private readonly _rowMetricOptions: Computed<MetricOptions> = private readonly _rowMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._rowCount, (_use, product, rowCount) => { Computed.create(this, this._currentFeatures, this._rowCount, (_use, features, rowCount) => {
const maxRows = product?.features.baseMaxRowsPerDocument; const maxRows = features?.baseMaxRowsPerDocument;
// Invalid row limits are currently treated as if they are undefined. // Invalid row limits are currently treated as if they are undefined.
const maxValue = maxRows && maxRows > 0 ? maxRows : undefined; const maxValue = maxRows && maxRows > 0 ? maxRows : undefined;
return { return {
@ -71,8 +72,8 @@ export class DocumentUsage extends Disposable {
}); });
private readonly _dataSizeMetricOptions: Computed<MetricOptions> = private readonly _dataSizeMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._dataSizeBytes, (_use, product, dataSize) => { Computed.create(this, this._currentFeatures, this._dataSizeBytes, (_use, features, dataSize) => {
const maxSize = product?.features.baseMaxDataSizePerDocument; const maxSize = features?.baseMaxDataSizePerDocument;
// Invalid data size limits are currently treated as if they are undefined. // Invalid data size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined; const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return { return {
@ -93,8 +94,8 @@ export class DocumentUsage extends Disposable {
}); });
private readonly _attachmentsSizeMetricOptions: Computed<MetricOptions> = private readonly _attachmentsSizeMetricOptions: Computed<MetricOptions> =
Computed.create(this, this._currentProduct, this._attachmentsSizeBytes, (_use, product, attachmentsSize) => { Computed.create(this, this._currentFeatures, this._attachmentsSizeBytes, (_use, features, attachmentsSize) => {
const maxSize = product?.features.baseMaxAttachmentsBytesPerDocument; const maxSize = features?.baseMaxAttachmentsBytesPerDocument;
// Invalid attachments size limits are currently treated as if they are undefined. // Invalid attachments size limits are currently treated as if they are undefined.
const maxValue = maxSize && maxSize > 0 ? maxSize : undefined; const maxValue = maxSize && maxSize > 0 ? maxSize : undefined;
return { return {
@ -156,11 +157,12 @@ export class DocumentUsage extends Disposable {
const org = use(this._currentOrg); const org = use(this._currentOrg);
const product = use(this._currentProduct); const product = use(this._currentProduct);
const features = use(this._currentFeatures);
const status = use(this._dataLimitStatus); const status = use(this._dataLimitStatus);
if (!org || !status) { return null; } if (!org || !status) { return null; }
return buildMessage([ return buildMessage([
buildLimitStatusMessage(status, product?.features, { buildLimitStatusMessage(status, features, {
disableRawDataLink: true disableRawDataLink: true
}), }),
(product && isFreePlan(product.name) (product && isFreePlan(product.name)
@ -195,7 +197,7 @@ export class DocumentUsage extends Disposable {
export function buildLimitStatusMessage( export function buildLimitStatusMessage(
status: NonNullable<DataLimitStatus>, status: NonNullable<DataLimitStatus>,
features?: Features, features?: Features|null,
options: { options: {
disableRawDataLink?: boolean; disableRawDataLink?: boolean;
} = {} } = {}

View File

@ -12,9 +12,10 @@ import {buildNewSiteModal, buildUpgradeModal} from 'app/client/ui/ProductUpgrade
import {SupportGristNudge} from 'app/client/ui/SupportGristNudge'; import {SupportGristNudge} from 'app/client/ui/SupportGristNudge';
import {gristThemePrefs} from 'app/client/ui2018/theme'; import {gristThemePrefs} from 'app/client/ui2018/theme';
import {AsyncCreate} from 'app/common/AsyncCreate'; import {AsyncCreate} from 'app/common/AsyncCreate';
import {PlanSelection} from 'app/common/BillingAPI';
import {ICustomWidget} from 'app/common/CustomWidget'; import {ICustomWidget} from 'app/common/CustomWidget';
import {OrgUsageSummary} from 'app/common/DocUsage'; 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 {GristLoadConfig, IGristUrlState} from 'app/common/gristUrls';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import {LocalPlugin} from 'app/common/plugin'; import {LocalPlugin} from 'app/common/plugin';
@ -112,7 +113,8 @@ export interface AppModel {
lastVisitedOrgDomain: Observable<string|null>; lastVisitedOrgDomain: Observable<string|null>;
currentProduct: Product|null; // The current org's product. 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>; userPrefsObs: Observable<UserPrefs>;
themePrefs: Observable<ThemePrefs>; themePrefs: Observable<ThemePrefs>;
@ -133,8 +135,8 @@ export interface AppModel {
supportGristNudge: SupportGristNudge; supportGristNudge: SupportGristNudge;
refreshOrgUsage(): Promise<void>; refreshOrgUsage(): Promise<void>;
showUpgradeModal(): void; showUpgradeModal(): Promise<void>;
showNewSiteModal(): void; showNewSiteModal(): Promise<void>;
isBillingManager(): boolean; // If user is a billing manager for this org isBillingManager(): boolean; // If user is a billing manager for this org
isSupport(): boolean; // If user is a Support user isSupport(): boolean; // If user is a Support user
isOwner(): boolean; // If user is an owner of this org 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 isInstallAdmin(): boolean; // Is user an admin of this installation
dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not. dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not.
switchUser(user: FullUser, org?: string): Promise<void>; switchUser(user: FullUser, org?: string): Promise<void>;
isFreePlan(): boolean;
} }
export interface TopAppModelOptions { 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 lastVisitedOrgDomain = this.autoDispose(sessionStorageObs('grist-last-visited-org-domain'));
public readonly currentProduct = this.currentOrg?.billingAccount?.product ?? null; 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 isPersonal = Boolean(this.currentOrg?.owner);
public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal; public readonly isTeamSite = Boolean(this.currentOrg) && !this.isPersonal;
@ -367,7 +374,17 @@ export class AppModelImpl extends Disposable implements AppModel {
if (state.createTeam) { if (state.createTeam) {
// Remove params from the URL. // Remove params from the URL.
urlState().pushUrl({createTeam: false, params: {}}, {avoidReload: true, replace: true}).catch(() => {}); 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) => { G.window.resetDismissedPopups = (seen = false) => {
@ -384,23 +401,28 @@ export class AppModelImpl extends Disposable implements AppModel {
return this.currentProduct?.name ?? null; return this.currentProduct?.name ?? null;
} }
public async showUpgradeModal() { public async showUpgradeModal(plan?: PlanSelection) {
if (this.planName && this.currentOrg) { if (this.planName && this.currentOrg) {
if (this.isPersonal) { if (this.isPersonal) {
this.showNewSiteModal(); await this.showNewSiteModal(plan);
} else if (this.isTeamSite) { } else if (this.isTeamSite) {
buildUpgradeModal(this, this.planName); await buildUpgradeModal(this, {
appModel: this,
pickPlan: plan,
reason: 'upgrade'
});
} else { } else {
throw new Error("Unexpected state"); throw new Error("Unexpected state");
} }
} }
} }
public showNewSiteModal(selectedPlan?: string) {
public async showNewSiteModal(plan?: PlanSelection) {
if (this.planName) { if (this.planName) {
buildNewSiteModal(this, { await buildNewSiteModal(this, {
planName: this.planName, appModel: this,
selectedPlan, plan,
onCreate: () => this.topAppModel.fetchUsersAndOrgs().catch(reportError) onCreate: () => this.topAppModel.fetchUsersAndOrgs().catch(reportError)
}); });
} }
@ -451,6 +473,10 @@ export class AppModelImpl extends Disposable implements AppModel {
this.lastVisitedOrgDomain.set(null); this.lastVisitedOrgDomain.set(null);
} }
public isFreePlan() {
return isFreePlan(this.planName || '');
}
private _updateLastVisitedOrgDomain({doc, org}: IGristUrlState, availableOrgs: Organization[]) { private _updateLastVisitedOrgDomain({doc, org}: IGristUrlState, availableOrgs: Organization[]) {
if ( if (
!org || !org ||

View File

@ -19,7 +19,7 @@ import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
import {delay} from 'app/common/delay'; import {delay} from 'app/common/delay';
import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI'; import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI';
import {FilteredDocUsageSummary} from 'app/common/DocUsage'; 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 {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
import {getReconnectTimeout} from 'app/common/gutil'; import {getReconnectTimeout} from 'app/common/gutil';
import {canEdit, isOwner} from 'app/common/roles'; 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. * changes, or a doc usage message is received from the server.
*/ */
currentProduct: Observable<Product|null>; 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. // This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.
currentDocId: Observable<string|undefined>; 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. * changes, or a doc usage message is received from the server.
*/ */
public readonly currentProduct = Observable.create<Product|null>(this, null); 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 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); 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) { constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
super(); 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) => { this.autoDispose(subscribe(urlState().state, (use, state) => {
const urlId = state.doc; const urlId = state.doc;
const urlOpenMode = state.mode; const urlOpenMode = state.mode;

View File

@ -382,7 +382,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
// Check if active product allows just a single workspace. // Check if active product allows just a single workspace.
function _isSingleWorkspaceMode(app: AppModel): boolean { 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 // Returns a default view mode preference. We used to show 'list' for everyone. We now default to

View File

@ -180,9 +180,9 @@ export class UserManagerModelImpl extends Disposable implements UserManagerModel
) { ) {
super(); super();
if (this._options.appModel) { if (this._options.appModel) {
const product = this._options.appModel.currentProduct; const features = this._options.appModel.currentFeatures;
const {supportEmail} = getGristConfig(); const {supportEmail} = getGristConfig();
this._shareAnnotator = new ShareAnnotator(product, initData, {supportEmail}); this._shareAnnotator = new ShareAnnotator(features, initData, {supportEmail});
} }
this.annotate(); this.annotate();
} }

View File

@ -139,7 +139,7 @@ export class AccountWidget extends Disposable {
// Show 'Organization Settings' when on a home page of a valid org. // Show 'Organization Settings' when on a home page of a valid org.
(!this._docPageModel && currentOrg && this._appModel.isTeamSite ? (!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"), roles.canEditAccess(currentOrg.access) ? t("Manage Team") : t("Access Details"),
testId('dm-org-access')) : testId('dm-org-access')) :
// Don't show on doc pages, or for personal orgs. // Don't show on doc pages, or for personal orgs.

View File

@ -117,7 +117,7 @@ export class AppHeader extends Disposable {
// Show 'Organization Settings' when on a home page of a valid org. // Show 'Organization Settings' when on a home page of a valid org.
(!this._docPageModel && this._currentOrg && !this._currentOrg.owner ? (!this._docPageModel && this._currentOrg && !this._currentOrg.owner ?
menuItem(() => manageTeamUsersApp(this._appModel), menuItem(() => manageTeamUsersApp({app: this._appModel}),
'Manage Team', testId('orgmenu-manage-team'), 'Manage Team', testId('orgmenu-manage-team'),
dom.cls('disabled', !roles.canEditAccess(this._currentOrg.access))) : dom.cls('disabled', !roles.canEditAccess(this._currentOrg.access))) :
// Don't show on doc pages, or for personal orgs. // Don't show on doc pages, or for personal orgs.

View File

@ -10,6 +10,7 @@ import {IModalControl, modal} from 'app/client/ui2018/modals';
import {TEAM_PLAN} from 'app/common/Features'; import {TEAM_PLAN} from 'app/common/Features';
import {checkSubdomainValidity} from 'app/common/orgNameUtils'; import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import {UserAPIImpl} from 'app/common/UserAPI'; import {UserAPIImpl} from 'app/common/UserAPI';
import {PlanSelection} from 'app/common/BillingAPI';
import { import {
Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId, Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId,
Observable, styled Observable, styled
@ -19,9 +20,9 @@ import { makeT } from '../lib/localization';
const t = makeT('CreateTeamModal'); const t = makeT('CreateTeamModal');
const testId = makeTestId('test-create-team-'); const testId = makeTestId('test-create-team-');
export function buildNewSiteModal(context: Disposable, options: { export async function buildNewSiteModal(context: Disposable, options: {
planName: string, appModel: AppModel,
selectedPlan?: string, plan?: PlanSelection,
onCreate?: () => void onCreate?: () => void
}) { }) {
const { onCreate } = options; 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`)); throw new UserError(t(`Billing is not supported in grist-core`));
} }

View File

@ -83,7 +83,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
css.docMenu( css.docMenu(
attachAddNewTip(home), attachAddNewTip(home),
dom.maybe(!home.app.currentFeatures.workspaces, () => [ dom.maybe(!home.app.currentFeatures?.workspaces, () => [
css.docListHeader(t("This service is not available right now")), css.docListHeader(t("This service is not available right now")),
dom('span', t("(The organization needs a paid plan)")), dom('span', t("(The organization needs a paid plan)")),
]), ]),

View File

@ -168,7 +168,7 @@ function buildButtons(homeModel: HomeModel, options: {
!options.invite ? null : !options.invite ? null :
cssBtn(cssBtnIcon('Help'), t("Invite Team Members"), testId('intro-invite'), cssBtn(cssBtnIcon('Help'), t("Invite Team Members"), testId('intro-invite'),
cssButton.cls('-primary'), cssButton.cls('-primary'),
dom.on('click', () => manageTeamUsersApp(homeModel.app)), dom.on('click', () => manageTeamUsersApp({app: homeModel.app})),
), ),
!options.templates ? null : !options.templates ? null :
cssBtn(cssBtnIcon('FieldTable'), t("Browse Templates"), testId('intro-templates'), cssBtn(cssBtnIcon('FieldTable'), t("Browse Templates"), testId('intro-templates'),

View File

@ -196,7 +196,7 @@ export async function importFromPluginAndOpen(home: HomeModel, source: ImportSou
function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] { function addMenu(home: HomeModel, creating: Observable<boolean>): DomElementArg[] {
const org = home.app.currentOrg; const org = home.app.currentOrg;
const orgAccess: roles.Role|null = org ? org.access : null; const orgAccess: roles.Role|null = org ? org.access : null;
const needUpgrade = home.app.currentFeatures.maxWorkspacesPerOrg === 1; const needUpgrade = home.app.currentFeatures?.maxWorkspacesPerOrg === 1;
return [ return [
menuItem(() => createDocAndOpen(home), menuIcon('Page'), t("Create Empty Document"), 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 [ return [
upgradableMenuItem(needUpgrade, () => renaming.set(ws), t("Rename"), upgradableMenuItem(needUpgrade, () => renaming.set(ws), t("Rename"),

View File

@ -140,8 +140,8 @@ class SaveCopyModal extends Disposable {
} }
// We won't have info about any other org except the one we are at. // We won't have info about any other org except the one we are at.
if (org.id === this._app.currentOrg?.id) { if (org.id === this._app.currentOrg?.id) {
const workspaces = this._app.currentOrg.billingAccount?.product.features.workspaces ?? true; const workspaces = this._app.currentFeatures?.workspaces ?? true;
const numberAllowed = this._app.currentOrg.billingAccount?.product.features.maxWorkspacesPerOrg ?? 2; const numberAllowed = this._app.currentFeatures?.maxWorkspacesPerOrg ?? 2;
return workspaces && numberAllowed > 1; return workspaces && numberAllowed > 1;
} }
return true; return true;

View File

@ -2,20 +2,33 @@ import {loadUserManager} from 'app/client/lib/imports';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {FullUser, Organization, UserAPI} from 'app/common/UserAPI'; 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. // 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, { (await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getOrgAccess(org.id), permissionData: api.getOrgAccess(org.id),
activeUser: user, activeUser: user,
resourceType: 'organization', resourceType: 'organization',
resourceId: org.id, resourceId: org.id,
resource: org, 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. // 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) { if (app.currentOrg) {
return manageTeamUsers(app.currentOrg, app.currentValidUser, app.api); return manageTeamUsers({org: app.currentOrg, user: app.currentValidUser, api: app.api, onSave});
} }
} }

View File

@ -23,7 +23,7 @@ import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, style
const t = makeT('TopBar'); const t = makeT('TopBar');
export function createTopBarHome(appModel: AppModel) { export function createTopBarHome(appModel: AppModel, onSave?: (personal: boolean) => Promise<unknown>){
const isAnonymous = !appModel.currentValidUser; const isAnonymous = !appModel.currentValidUser;
return [ return [
@ -32,7 +32,7 @@ export function createTopBarHome(appModel: AppModel) {
[ [
basicButton( basicButton(
t("Manage Team"), t("Manage Team"),
dom.on('click', () => manageTeamUsersApp(appModel)), dom.on('click', () => manageTeamUsersApp({app: appModel, onSave})),
testId('topbar-manage-team') testId('topbar-manage-team')
), ),
cssSpacer() cssSpacer()

View File

@ -1,5 +1,5 @@
import {theme} from 'app/client/ui2018/cssVars'; import {theme} from 'app/client/ui2018/cssVars';
import {DomArg, keyframes, styled} from 'grainjs'; import {DomArg, keyframes, Observable, observable, styled} from 'grainjs';
const rotate360 = keyframes(` const rotate360 = keyframes(`
from { transform: rotate(45deg); } 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', ` const cssLoadingDotsContainer = styled('div', `
--dot-size: 10px; --dot-size: 10px;
display: inline-flex; display: inline-flex;

View File

@ -416,8 +416,8 @@ export function confirmModal(
*/ */
export function promptModal( export function promptModal(
title: string, title: string,
onConfirm: (text: string) => Promise<unknown>, onConfirm: (text: string) => Promise<void>,
btnText: string, btnText?: string,
initial?: string, initial?: string,
placeholder?: string, placeholder?: string,
onCancel?: () => void onCancel?: () => void
@ -429,7 +429,7 @@ export function promptModal(
const options: ISaveModalOptions = { const options: ISaveModalOptions = {
title, title,
body: txtInput, body: txtInput,
saveLabel: btnText, saveLabel: btnText || t('Save'),
saveFunc: () => { saveFunc: () => {
// Mark that confirm was invoked. // Mark that confirm was invoked.
confirmed = true; confirmed = true;

View File

@ -381,7 +381,7 @@ export class FormulaAssistant extends Disposable {
canUpgradeSite ? t('upgrade to the Pro Team plan') : t('upgrade your plan'), canUpgradeSite ? t('upgrade to the Pro Team plan') : t('upgrade your plan'),
dom.on('click', async () => { dom.on('click', async () => {
if (canUpgradeSite) { if (canUpgradeSite) {
this._gristDoc.appModel.showUpgradeModal(); this._gristDoc.appModel.showUpgradeModal().catch(reportError);
} else { } else {
await urlState().pushUrl({billing: 'billing'}); await urlState().pushUrl({billing: 'billing'});
} }

View File

@ -1,4 +1,5 @@
import {BaseAPI, IOptions} from 'app/common/BaseAPI'; import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {TEAM_FREE_PLAN} from 'app/common/Features';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import {StringUnion} from 'app/common/StringUnion'; import {StringUnion} from 'app/common/StringUnion';
import {addCurrentOrgToPath} from 'app/common/urlUtils'; import {addCurrentOrgToPath} from 'app/common/urlUtils';
@ -24,23 +25,22 @@ export type BillingTask = typeof BillingTask.type;
export interface IBillingPlan { export interface IBillingPlan {
id: string; // the Stripe plan id id: string; // the Stripe plan id
nickname: string; nickname: string;
currency: string; // lowercase three-letter ISO currency code interval: 'day'|'week'|'month'|'year'; // billing frequency - one of day, week, month or year
interval: string; // billing frequency - one of day, week, month or year // Merged metadata from price and product.
amount: number; // amount in cents charged at each interval
metadata: { metadata: {
family?: string; // groups plans for filtering by GRIST_STRIPE_FAMILY env variable 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. 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. gristProduct: string; // name of grist product that should be used with this plan.
unthrottledApi: boolean; type: string; // type of the plan (either plan or limit for now)
customSubdomain: boolean; minimumUnits?: number; // minimum number of units for the plan
workspaces: boolean; gristLimit?: string; // type of the limit (for limit type plans)
maxDocs?: number; // if given, limit of docs that can be created
maxUsersPerDoc?: number; // if given, limit of users each doc can be shared with
}; };
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. product: string; // the Stripe product id.
features: string[]; // list of features that are available with this plan
active: boolean; active: boolean;
name: string; // the name of the product
} }
export interface ILimitTier { export interface ILimitTier {
@ -95,16 +95,22 @@ export interface IBillingSubscription {
valueRemaining: number; valueRemaining: number;
// The effective tax rate of the customer for the given address. // The effective tax rate of the customer for the given address.
taxRate: number; 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. // The current number of users with whom the paid org is shared.
userCount: number; userCount: number;
// The next total in cents that Stripe is going to charge (includes tax and discount). // The next total in cents that Stripe is going to charge (includes tax and discount).
nextTotal: number; nextTotal: number;
// The next due date in milliseconds.
nextDueDate: number|null; // in milliseconds
// Discount information, if any. // Discount information, if any.
discount: IBillingDiscount|null; discount: IBillingDiscount|null;
// Last plan we had a subscription for, if any. // Last plan we had a subscription for, if any.
lastPlanId: string|null; lastPlanId: string|null;
// Whether there is a valid plan in effect. // Whether there is a valid plan in effect.
isValidPlan: boolean; 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. // A flag for when all is well with the user's subscription.
inGoodStanding: boolean; inGoodStanding: boolean;
// Whether there is a paying valid account (even on free plan). It this is set // Whether there is a paying valid account (even on free plan). It this is set
@ -120,9 +126,18 @@ export interface IBillingSubscription {
// such as "active", "trialing" (reflected in isInTrial), "incomplete", etc. // such as "active", "trialing" (reflected in isInTrial), "incomplete", etc.
status?: string; status?: string;
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice. 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. 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. lastChargeTime?: number; // The time of the last charge attempt.
limit?: ILimit|null; 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 { export interface ILimit {
@ -143,22 +158,72 @@ export interface FullBillingAccount extends BillingAccount {
managers: FullUser[]; 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 { export interface BillingAPI {
isDomainAvailable(domain: string): Promise<boolean>; isDomainAvailable(domain: string): Promise<boolean>;
getPlans(): Promise<IBillingPlan[]>; getPlans(plan?: PlanSelection): Promise<IBillingPlan[]>;
getSubscription(): Promise<IBillingSubscription>; getSubscription(): Promise<IBillingSubscription>;
getBillingAccount(): Promise<FullBillingAccount>; getBillingAccount(): Promise<FullBillingAccount>;
updateBillingManagers(delta: ManagerDelta): Promise<void>; updateBillingManagers(delta: ManagerDelta): Promise<void>;
updateSettings(settings: IBillingOrgSettings): Promise<void>; updateSettings(settings: IBillingOrgSettings): Promise<void>;
subscriptionStatus(planId: string): Promise<boolean>; subscriptionStatus(planId: string): Promise<boolean>;
createFreeTeam(name: string, domain: string): Promise<string>; createFreeTeam(name: string, domain: string): Promise<void>;
createTeam(name: string, domain: string): Promise<string>; createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{
upgrade(): Promise<string>; checkoutUrl?: string,
orgUrl?: string,
}>;
confirmChange(plan: PlanSelection): Promise<UpgradeConfirmation>;
changePlan(plan: PlanSelection): Promise<void>;
renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}>;
cancelCurrentPlan(): Promise<void>; cancelCurrentPlan(): Promise<void>;
downgradePlan(planName: string): Promise<void>;
renewPlan(): string;
customerPortal(): string; customerPortal(): string;
updateAssistantPlan(tier: number): Promise<void>; 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 { export class BillingAPIImpl extends BaseAPI implements BillingAPI {
@ -172,8 +237,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
body: JSON.stringify({ domain }) body: JSON.stringify({ domain })
}); });
} }
public async getPlans(): Promise<IBillingPlan[]> { public async getPlans(plan?: PlanSelection): Promise<IBillingPlan[]> {
return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'}); 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 // 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> { public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
await this.request(`${this._url}/api/billing/settings`, { await this.request(`${this._url}/api/billing/settings`, {
method: 'POST', method: 'POST',
@ -212,43 +275,53 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
}); });
} }
public async createFreeTeam(name: string, domain: string): Promise<string> { public async createTeam(name: string, domain: string, plan: {
const data = await this.requestJson(`${this._url}/api/billing/team-free`, { product?: string, priceId?: string, count?: number
method: 'POST', }, next?: string): Promise<{
body: JSON.stringify({ checkoutUrl?: string,
domain, orgUrl?: string,
name }> {
})
});
return data.orgUrl;
}
public async createTeam(name: string, domain: string): Promise<string> {
const data = await this.requestJson(`${this._url}/api/billing/team`, { const data = await this.requestJson(`${this._url}/api/billing/team`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
domain, domain,
name, name,
planType: 'team', ...plan,
next: window.location.href next
}) })
}); });
return data.checkoutUrl; return data;
} }
public async upgrade(): Promise<string> { public async createFreeTeam(name: string, domain: string): Promise<void> {
const data = await this.requestJson(`${this._url}/api/billing/upgrade`, { await this.createTeam(name, domain, {
method: 'POST', 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 { public customerPortal(): string {
return `${this._url}/api/billing/customer-portal`; return `${this._url}/api/billing/customer-portal`;
} }
public renewPlan(): string { public renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}> {
return `${this._url}/api/billing/renew`; return this.requestJson(`${this._url}/api/billing/renew`, {
method: 'POST',
body: JSON.stringify(plan)
});
} }
public async updateAssistantPlan(tier: number): Promise<void> { public async updateAssistantPlan(tier: number): Promise<void> {
@ -269,6 +342,39 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
return data.active; 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 { private get _url(): string {
return addCurrentOrgToPath(this._homeUrl); return addCurrentOrgToPath(this._homeUrl);
} }

52
app/common/Features-ti.ts Normal file
View 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;

View File

@ -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 { export interface SnapshotWindow {
count: number; count: number;
unit: 'days' | 'month' | 'year'; 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 // 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 // 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. // 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 // 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; return features.maxWorkspacesPerOrg !== 1;
} }
// Grist is aware only about those plans.
export const PERSONAL_LEGACY_PLAN = 'starter'; // Those plans are synchronized with database only if they don't exists currently.
export const PERSONAL_FREE_PLAN = 'personalFree'; export const PERSONAL_FREE_PLAN = 'personalFree';
export const TEAM_FREE_PLAN = 'teamFree'; 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 TEAM_PLAN = 'team';
export const displayPlanName: { [key: string]: string } = { export const displayPlanName: { [key: string]: string } = {
[PERSONAL_LEGACY_PLAN]: 'Free Personal (Legacy)',
[PERSONAL_FREE_PLAN]: 'Free Personal', [PERSONAL_FREE_PLAN]: 'Free Personal',
[TEAM_FREE_PLAN]: 'Team Free', [TEAM_FREE_PLAN]: 'Team Free',
[SUSPENDED_PLAN]: 'Suspended',
[ANONYMOUS_PLAN]: 'Anonymous',
[FREE_PLAN]: 'Free',
[TEAM_PLAN]: 'Pro' [TEAM_PLAN]: 'Pro'
} as const; } as const;
// Returns true if `planName` is for a personal product. // Returns true if `planName` is for a legacy product.
export function isPersonalPlan(planName: string): boolean { export function isLegacyPlan(planName: string): boolean {
return isFreePersonalPlan(planName); return planName === PERSONAL_LEGACY_PLAN;
} }
// Returns true if `planName` is for a free personal product. // 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); return [PERSONAL_LEGACY_PLAN, PERSONAL_FREE_PLAN].includes(planName);
} }
// Returns true if `planName` is for a legacy product. /**
export function isLegacyPlan(planName: string): boolean { * Actually all known plans don't require billing (which doesn't mean they are free actually, as it can
return isFreeLegacyPlan(planName); * be overridden by Stripe). There are also pro (team) and enterprise plans, which are billable, but they are
} * read from Stripe.
*/
// 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.
export function isFreePlan(planName: string): boolean { export function isFreePlan(planName: string): boolean {
return ( switch (planName) {
isFreePersonalPlan(planName) || case PERSONAL_LEGACY_PLAN:
isFreeTeamPlan(planName) || case PERSONAL_FREE_PLAN:
isFreeLegacyPlan(planName) || case TEAM_FREE_PLAN:
planName === 'Free' 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;
}
} }

View File

@ -1,4 +1,4 @@
import { isTeamPlan, Product } from 'app/common/Features'; import { Features } from 'app/common/Features';
import { normalizeEmail } from 'app/common/emails'; import { normalizeEmail } from 'app/common/emails';
import { PermissionData, PermissionDelta } from 'app/common/UserAPI'; import { PermissionData, PermissionDelta } from 'app/common/UserAPI';
@ -37,11 +37,10 @@ export interface ShareAnnotatorOptions {
* current shares in place. * current shares in place.
*/ */
export class ShareAnnotator { export class ShareAnnotator {
private _features = this._product?.features ?? {};
private _supportEmail = this._options.supportEmail; private _supportEmail = this._options.supportEmail;
constructor( constructor(
private _product: Product|null, private _features: Features|null,
private _state: PermissionData, private _state: PermissionData,
private _options: ShareAnnotatorOptions = {} private _options: ShareAnnotatorOptions = {}
) { ) {
@ -52,9 +51,9 @@ export class ShareAnnotator {
} }
public annotateChanges(change: PermissionDelta): ShareAnnotations { public annotateChanges(change: PermissionDelta): ShareAnnotations {
const features = this._features; const features = this._features ?? {};
const annotations: ShareAnnotations = { const annotations: ShareAnnotations = {
hasTeam: !this._product || isTeamPlan(this._product.name), hasTeam: !this._features || this._features.vanityDomain,
users: new Map(), users: new Map(),
}; };
if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) { if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) {

View File

@ -9,7 +9,7 @@ import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues,
TableRecordValuesWithoutIds, UserAction} from 'app/common/DocActions'; TableRecordValuesWithoutIds, UserAction} from 'app/common/DocActions';
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI'; import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
import {OrgUsageSummary} from 'app/common/DocUsage'; 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 {isClient} from 'app/common/gristUrls';
import {encodeQueryParams} from 'app/common/gutil'; import {encodeQueryParams} from 'app/common/gutil';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI'; import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
@ -75,8 +75,10 @@ export interface BillingAccount {
id: number; id: number;
individual: boolean; individual: boolean;
product: Product; product: Product;
stripePlanId: string; // Stripe price id.
isManager: boolean; isManager: boolean;
inGoodStanding: boolean; inGoodStanding: boolean;
features: Features;
externalOptions?: { externalOptions?: {
invoiceId?: string; invoiceId?: string;
}; };

View File

@ -134,8 +134,9 @@ export interface IGristUrlState {
docTour?: boolean; docTour?: boolean;
manageUsers?: boolean; manageUsers?: boolean;
createTeam?: boolean; createTeam?: boolean;
upgradeTeam?: boolean;
params?: { params?: {
billingPlan?: string; billingPlan?: string; // priceId
planType?: string; planType?: string;
billingTask?: BillingTask; billingTask?: BillingTask;
embed?: boolean; embed?: boolean;
@ -358,6 +359,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
url.hash = 'manage-users'; url.hash = 'manage-users';
} else if (state.createTeam) { } else if (state.createTeam) {
url.hash = 'create-team'; url.hash = 'create-team';
} else if (state.upgradeTeam) {
url.hash = 'upgrade-team';
} else { } else {
url.hash = ''; url.hash = '';
} }
@ -573,6 +576,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
state.docTour = hashMap.get('#') === 'repeat-doc-tour'; state.docTour = hashMap.get('#') === 'repeat-doc-tour';
state.manageUsers = hashMap.get('#') === 'manage-users'; state.manageUsers = hashMap.get('#') === 'manage-users';
state.createTeam = hashMap.get('#') === 'create-team'; state.createTeam = hashMap.get('#') === 'create-team';
state.upgradeTeam = hashMap.get('#') === 'upgrade-team';
} }
return state; return state;
} }

View File

@ -72,7 +72,7 @@ export function addOrg(
userId: number, userId: number,
props: Partial<OrganizationProperties>, props: Partial<OrganizationProperties>,
options?: { options?: {
planType?: string, product?: string,
billing?: BillingOptions, billing?: BillingOptions,
} }
): Promise<number> { ): Promise<number> {

View File

@ -4,6 +4,7 @@ import {Organization} from 'app/gen-server/entity/Organization';
import {Product} from 'app/gen-server/entity/Product'; import {Product} from 'app/gen-server/entity/Product';
import {nativeValues} from 'app/gen-server/lib/values'; import {nativeValues} from 'app/gen-server/lib/values';
import {Limit} from 'app/gen-server/entity/Limit'; 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 // This type is for billing account status information. Intended for stuff
// like "free trial running out in N days". // like "free trial running out in N days".
@ -35,6 +36,9 @@ export class BillingAccount extends BaseEntity {
@JoinColumn({name: 'product_id'}) @JoinColumn({name: 'product_id'})
public product: Product; public product: Product;
@Column({type: nativeValues.jsonEntityType, nullable: true})
public features: Features|null;
@Column({type: Boolean}) @Column({type: Boolean})
public individual: boolean; public individual: boolean;
@ -57,6 +61,9 @@ export class BillingAccount extends BaseEntity {
@Column({name: 'stripe_plan_id', type: String, nullable: true}) @Column({name: 'stripe_plan_id', type: String, nullable: true})
public stripePlanId: string | null; public stripePlanId: string | null;
@Column({name: 'payment_link', type: String, nullable: true})
public paymentLink: string | null;
@Column({name: 'external_id', type: String, nullable: true}) @Column({name: 'external_id', type: String, nullable: true})
public externalId: string | null; public externalId: string | null;
@ -66,6 +73,7 @@ export class BillingAccount extends BaseEntity {
@OneToMany(type => BillingAccountManager, manager => manager.billingAccount) @OneToMany(type => BillingAccountManager, manager => manager.billingAccount)
public managers: BillingAccountManager[]; public managers: BillingAccountManager[];
// Only one billing account per organization.
@OneToMany(type => Organization, org => org.billingAccount) @OneToMany(type => Organization, org => org.billingAccount)
public orgs: Organization[]; 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. // 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) // (No @Column needed since calculation is done in javascript not sql)
public isManager?: boolean; public isManager?: boolean;
public getFeatures(): Features {
return mergedFeatures(this.features, this.product.features) ?? {};
}
} }

View File

@ -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'; TEAM_PLAN} from 'app/common/Features';
import {nativeValues} from 'app/gen-server/lib/values'; import {nativeValues} from 'app/gen-server/lib/values';
import * as assert from 'assert'; 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 = { export const teamFeatures: Features = {
workspaces: true, workspaces: true,
@ -71,16 +79,11 @@ export const teamFreeFeatures: Features = {
baseMaxAssistantCalls: 100, baseMaxAssistantCalls: 100,
}; };
export const testDailyApiLimitFeatures = {
...teamFreeFeatures,
baseMaxApiUnitsPerDocumentPerDay: 3,
};
/** /**
* A summary of features used in unrestricted grandfathered accounts, and also * A summary of features used in unrestricted grandfathered accounts, and also
* in some test settings. * in some test settings.
*/ */
export const grandfatherFeatures: Features = { export const freeAllFeatures: Features = {
workspaces: true, workspaces: true,
vanityDomain: true, vanityDomain: true,
}; };
@ -98,61 +101,44 @@ export const suspendedFeatures: Features = {
/** /**
* *
* Products are a bundle of enabled features. Most products in * Products are a bundle of enabled features. Grist knows only
* Grist correspond to products in stripe. The correspondence is * about free products and creates them by default. Other products
* established by a gristProduct metadata field on stripe plans. * are created by the billing system (Stripe) and synchronized when used
* * or via webhooks.
* 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.
*
*/ */
export const PRODUCTS: IProduct[] = [ 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, name: PERSONAL_LEGACY_PLAN,
features: personalLegacyFeatures, features: personalLegacyFeatures,
}, },
{ {
name: 'professional', // deprecated, can be removed once no longer referred to in stripe. name: PERSONAL_FREE_PLAN,
features: teamFeatures, 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, name: TEAM_PLAN,
features: teamFeatures features: teamFeatures
}, },
// This is a product for a team site that is no longer in good standing, but isn't yet // This is a product for a team site that is no longer in good standing, but isn't yet
// to be removed / deactivated entirely. // to be removed / deactivated entirely.
{ {
name: 'suspended', name: SUSPENDED_PLAN,
features: suspendedFeatures features: suspendedFeatures,
}, },
{ {
name: TEAM_FREE_PLAN, name: FREE_PLAN,
features: teamFreeFeatures features: freeAllFeatures,
}, },
// This is a product for newly created accounts/orgs.
{ {
name: PERSONAL_FREE_PLAN, name: STUB_PLAN,
features: personalFreeFeatures, features: {},
}, }
]; ];
@ -161,7 +147,6 @@ export const PRODUCTS: IProduct[] = [
*/ */
export function getDefaultProductNames() { export function getDefaultProductNames() {
const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT; const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;
// TODO: can be removed once new deal is released.
const personalFreePlan = PERSONAL_FREE_PLAN; const personalFreePlan = PERSONAL_FREE_PLAN;
return { return {
// Personal site start off on a functional plan. // Personal site start off on a functional plan.
@ -218,6 +203,12 @@ export async function synchronizeProducts(
.map(p => [p.name, p])); .map(p => [p.name, p]));
for (const product of desiredProducts.values()) { for (const product of desiredProducts.values()) {
if (existingProducts.has(product.name)) { 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)!; const p = existingProducts.get(product.name)!;
try { try {
assert.deepStrictEqual(p.features, product.features); assert.deepStrictEqual(p.features, product.features);

View File

@ -4,7 +4,7 @@ import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
import {getDataLimitStatus} from 'app/common/DocLimits'; import {getDataLimitStatus} from 'app/common/DocLimits';
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage'; import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
import {normalizeEmail} from 'app/common/emails'; 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 {buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId} from 'app/common/gristUrls';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI'; import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
import {checkSubdomainValidity} from 'app/common/orgNameUtils'; import {checkSubdomainValidity} from 'app/common/orgNameUtils';
@ -72,6 +72,7 @@ import {
import uuidv4 from "uuid/v4"; import uuidv4 from "uuid/v4";
import flatten = require('lodash/flatten'); import flatten = require('lodash/flatten');
import pick = require('lodash/pick'); import pick = require('lodash/pick');
import defaultsDeep = require('lodash/defaultsDeep');
// Support transactions in Sqlite in async code. This is a monkey patch, affecting // Support transactions in Sqlite in async code. This is a monkey patch, affecting
// the prototypes of various TypeORM classes. // the prototypes of various TypeORM classes.
@ -264,16 +265,18 @@ interface CreateWorkspaceOptions {
/** /**
* Available options for creating a new org with a new billing account. * 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, export type BillingOptions = Partial<Pick<BillingAccount,
'product' |
'stripeCustomerId' | 'stripeCustomerId' |
'stripeSubscriptionId' | 'stripeSubscriptionId' |
'stripePlanId' | 'stripePlanId' |
'externalId' | 'externalId' |
'externalOptions' | 'externalOptions' |
'inGoodStanding' | 'inGoodStanding' |
'status' 'status' |
'paymentLink' |
'features'
>>; >>;
/** /**
@ -748,7 +751,8 @@ export class HomeDBManager extends EventEmitter {
// get a bit confusing. // get a bit confusing.
const result = await this.addOrg(user, {name: "Personal"}, { const result = await this.addOrg(user, {name: "Personal"}, {
setUserAsOwner: true, setUserAsOwner: true,
useNewPlan: true useNewPlan: true,
product: PERSONAL_FREE_PLAN,
}, manager); }, manager);
if (result.status !== 200) { if (result.status !== 200) {
throw new Error(result.errMessage); throw new Error(result.errMessage);
@ -808,22 +812,17 @@ export class HomeDBManager extends EventEmitter {
* and orgs.acl_rules.group.memberUsers should be included. * and orgs.acl_rules.group.memberUsers should be included.
*/ */
public async getOrgMemberCount(org: string|number|Organization): Promise<number> { public async getOrgMemberCount(org: string|number|Organization): Promise<number> {
if (!(org instanceof Organization)) { return (await this._getOrgMembers(org)).length;
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; * 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, id: 0,
individual: true, individual: true,
product: { product: {
name: 'anonymous', name: ANONYMOUS_PLAN,
features: personalFreeFeatures, features: personalFreeFeatures,
}, },
stripePlanId: '',
isManager: false, isManager: false,
inGoodStanding: true, inGoodStanding: true,
features: {},
}, },
host: null host: null
}; };
@ -1080,7 +1081,7 @@ export class HomeDBManager extends EventEmitter {
orgQuery = this._addFeatures(orgQuery); orgQuery = this._addFeatures(orgQuery);
const orgQueryResult = await verifyEntity(orgQuery); const orgQueryResult = await verifyEntity(orgQuery);
const org: Organization = this.unwrapQueryResult(orgQueryResult); 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. // Grab all the non-removed documents in the org.
let docsQuery = this._docs() 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 === 0) { throw new ApiError('document not found', 404); }
if (docs.length > 1) { throw new ApiError('ambiguous document request', 400); } if (docs.length > 1) { throw new ApiError('ambiguous document request', 400); }
doc = docs[0]; doc = docs[0];
const features = doc.workspace.org.billingAccount.product.features; const features = doc.workspace.org.billingAccount.getFeatures();
if (features.readOnlyDocs || this._restrictedMode) { if (features.readOnlyDocs || this._restrictedMode) {
// Don't allow any access to docs that is stronger than "viewers". // Don't allow any access to docs that is stronger than "viewers".
doc.access = roles.getWeakestRole('viewers', doc.access); 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 * 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. * 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. * 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. * meaningful for team sites currently.
* @param billing: if set, controls the billing account settings for the org. * @param billing: if set, controls the billing account settings for the org.
*/ */
public async addOrg(user: User, props: Partial<OrganizationProperties>, public async addOrg(user: User, props: Partial<OrganizationProperties>,
options: { setUserAsOwner: boolean, options: { setUserAsOwner: boolean,
useNewPlan: boolean, useNewPlan: boolean,
planType?: string, product?: string, // Default to PERSONAL_FREE_PLAN or TEAM_FREE_PLAN env variable.
billing?: BillingOptions}, billing?: BillingOptions},
transaction?: EntityManager): Promise<QueryResult<number>> { transaction?: EntityManager): Promise<QueryResult<number>> {
const notifications: Array<() => void> = []; const notifications: Array<() => void> = [];
@ -1434,20 +1435,21 @@ export class HomeDBManager extends EventEmitter {
let billingAccount; let billingAccount;
if (options.useNewPlan) { // use separate billing account (currently yes) if (options.useNewPlan) { // use separate billing account (currently yes)
const productNames = getDefaultProductNames(); const productNames = getDefaultProductNames();
let productName = options.setUserAsOwner ? productNames.personal : const product =
options.planType === productNames.teamFree ? productNames.teamFree : productNames.teamInitial; // 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 // A bit fragile: this is called during creation of support@ user, before
// getSupportUserId() is available, but with setUserAsOwner of true. // getSupportUserId() is available, but with setUserAsOwner of true.
if (!options.setUserAsOwner user.id === this.getSupportUserId() ? productNames.team :
&& user.id === this.getSupportUserId() // Otherwise use teamInitial product (a stub).
&& options.planType !== productNames.teamFree) { productNames.teamInitial;
// 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;
}
billingAccount = new BillingAccount(); billingAccount = new BillingAccount();
billingAccount.individual = options.setUserAsOwner; billingAccount.individual = options.setUserAsOwner;
const dbProduct = await manager.findOne(Product, {where: {name: productName}}); const dbProduct = await manager.findOne(Product, {where: {name: product}});
if (!dbProduct) { if (!dbProduct) {
throw new Error('Cannot find product for new organization'); 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. // Apply billing settings if requested, but not all of them.
if (options.billing) { if (options.billing) {
const billing = 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> = [ const allowedKeys: Array<keyof BillingOptions> = [
'product',
'stripeCustomerId', 'stripeCustomerId',
'stripeSubscriptionId', 'stripeSubscriptionId',
'stripePlanId', 'stripePlanId',
'features',
// save will fail if externalId is a duplicate. // save will fail if externalId is a duplicate.
'externalId', 'externalId',
'externalOptions', 'externalOptions',
'inGoodStanding', 'inGoodStanding',
'status' 'status',
'paymentLink'
]; ];
Object.keys(billing).forEach(key => { Object.keys(billing).forEach(key => {
if (!allowedKeys.includes(key as any)) { if (!allowedKeys.includes(key as any)) {
@ -1721,7 +1728,7 @@ export class HomeDBManager extends EventEmitter {
return queryResult; return queryResult;
} }
const org: Organization = queryResult.data; const org: Organization = queryResult.data;
const features = org.billingAccount.product.features; const features = org.billingAccount.getFeatures();
if (features.maxWorkspacesPerOrg !== undefined) { if (features.maxWorkspacesPerOrg !== undefined) {
// we need to count how many workspaces are in the current org, and if we // we need to count how many workspaces are in the current org, and if we
// are already at or above the limit, then fail. // are already at or above the limit, then fail.
@ -2131,7 +2138,7 @@ export class HomeDBManager extends EventEmitter {
// of other information. // of other information.
const updated = pick(billingAccountCopy, 'inGoodStanding', 'status', 'stripeCustomerId', const updated = pick(billingAccountCopy, 'inGoodStanding', 'status', 'stripeCustomerId',
'stripeSubscriptionId', 'stripePlanId', 'product', 'externalId', 'stripeSubscriptionId', 'stripePlanId', 'product', 'externalId',
'externalOptions'); 'externalOptions', 'paymentLink');
billingAccount.paid = undefined; // workaround for a typeorm bug fixed upstream in billingAccount.paid = undefined; // workaround for a typeorm bug fixed upstream in
// https://github.com/typeorm/typeorm/pull/4035 // https://github.com/typeorm/typeorm/pull/4035
await transaction.save(Object.assign(billingAccount, updated)); await transaction.save(Object.assign(billingAccount, updated));
@ -2313,7 +2320,7 @@ export class HomeDBManager extends EventEmitter {
await this._updateUserPermissions(groups, userIdDelta, manager); await this._updateUserPermissions(groups, userIdDelta, manager);
this._checkUserChangeAllowed(userId, groups); this._checkUserChangeAllowed(userId, groups);
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups); const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
const features = ws.org.billingAccount.product.features; const features = ws.org.billingAccount.getFeatures();
const limit = features.maxSharesPerWorkspace; const limit = features.maxSharesPerWorkspace;
if (limit !== undefined) { if (limit !== undefined) {
this._restrictShares(null, limit, removeRole(nonOrgMembersBefore), this._restrictShares(null, limit, removeRole(nonOrgMembersBefore),
@ -2367,7 +2374,7 @@ export class HomeDBManager extends EventEmitter {
await this._updateUserPermissions(groups, userIdDelta, manager); await this._updateUserPermissions(groups, userIdDelta, manager);
this._checkUserChangeAllowed(userId, groups); this._checkUserChangeAllowed(userId, groups);
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups); const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
const features = org.billingAccount.product.features; const features = org.billingAccount.getFeatures();
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter); this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter);
} }
await manager.save(groups); await manager.save(groups);
@ -2629,7 +2636,7 @@ export class HomeDBManager extends EventEmitter {
const destOrgGroups = getNonGuestGroups(destOrg); const destOrgGroups = getNonGuestGroups(destOrg);
const nonOrgMembersBefore = this._getUserDifference(docGroups, sourceOrgGroups); const nonOrgMembersBefore = this._getUserDifference(docGroups, sourceOrgGroups);
const nonOrgMembersAfter = this._getUserDifference(docGroups, destOrgGroups); const nonOrgMembersAfter = this._getUserDifference(docGroups, destOrgGroups);
const features = destOrg.billingAccount.product.features; const features = destOrg.billingAccount.getFeatures();
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter, false); this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter, false);
} }
} }
@ -2768,6 +2775,32 @@ export class HomeDBManager extends EventEmitter {
.execute(); .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> { public async getDocProduct(docId: string): Promise<Product | undefined> {
return await this._connection.createQueryBuilder() return await this._connection.createQueryBuilder()
.select('product') .select('product')
@ -3011,11 +3044,11 @@ export class HomeDBManager extends EventEmitter {
} }
let existing = org?.billingAccount?.limits?.[0]; let existing = org?.billingAccount?.limits?.[0];
if (!existing) { if (!existing) {
const product = org?.billingAccount?.product; const features = org?.billingAccount?.getFeatures();
if (!product) { if (!features) {
throw new ApiError(`getLimit: no product found for org`, 500); 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 // 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. // track usage as it is basically unlimited.
return null; return null;
@ -3023,7 +3056,7 @@ export class HomeDBManager extends EventEmitter {
existing = new Limit(); existing = new Limit();
existing.billingAccountId = org.billingAccountId; existing.billingAccountId = org.billingAccountId;
existing.type = limitType; existing.type = limitType;
existing.limit = product.features.baseMaxAssistantCalls ?? 0; existing.limit = features.baseMaxAssistantCalls ?? 0;
existing.usage = 0; existing.usage = 0;
} }
const limitLess = existing.limit === -1; // -1 means no limit, it is not possible to do in stripe. 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(); .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> { private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise<Limit|null> {
if (accountId === 0) { if (accountId === 0) {
throw new Error(`getLimit: called for not existing account`); throw new Error(`getLimit: called for not existing account`);
@ -4196,7 +4248,7 @@ export class HomeDBManager extends EventEmitter {
if (value.billingAccount) { if (value.billingAccount) {
// This is an organization with billing account information available. Check limits. // This is an organization with billing account information available. Check limits.
const org = value as Organization; const org = value as Organization;
const features = org.billingAccount.product.features; const features = org.billingAccount.getFeatures();
if (!features.vanityDomain) { if (!features.vanityDomain) {
// Vanity domain not allowed for this org. // Vanity domain not allowed for this org.
options = {...options, suppressDomain: true}; 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. // Throw an error if there's no room for adding another document.
private async _checkRoomForAnotherDoc(workspace: Workspace, manager: EntityManager) { private async _checkRoomForAnotherDoc(workspace: Workspace, manager: EntityManager) {
const features = workspace.org.billingAccount.product.features; const features = workspace.org.billingAccount.getFeatures();
if (features.maxDocsPerOrg !== undefined) { if (features.maxDocsPerOrg !== undefined) {
// we need to count how many docs are in the current org, and if we // we need to count how many docs are in the current org, and if we
// are already at or above the limit, then fail. // are already at or above the limit, then fail.

View 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');
}
}

View File

@ -176,7 +176,7 @@ export function addSiteCommand(program: commander.Command,
}, { }, {
setUserAsOwner: false, setUserAsOwner: false,
useNewPlan: true, useNewPlan: true,
planType: 'teamFree' product: 'teamFree'
})); }));
}); });
} }

View File

@ -1893,7 +1893,7 @@ export class DocWorkerApi {
// or to be wrongly rejected after upgrading. // or to be wrongly rejected after upgrading.
const doc = (req as RequestWithLogin).docAuth!.cachedDoc!; 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) { if (!max) {
// This doc has no associated product (happens to new unsaved docs) // This doc has no associated product (happens to new unsaved docs)
// or the product has no API limit. Allow the request through. // or the product has no API limit. Allow the request through.

View File

@ -1092,7 +1092,7 @@ export class FlexServer implements GristServer {
// If "welcomeNewUser" is ever added to billing pages, we'd need // If "welcomeNewUser" is ever added to billing pages, we'd need
// to avoid a redirect loop. // 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}` : ''; const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : '';
return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`); return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`);
} }

View File

@ -175,8 +175,8 @@ export class HostedStorageManager implements IDocStorageManager {
return path.join(dir, 'meta.json'); return path.join(dir, 'meta.json');
}, },
async docId => { async docId => {
const product = await dbManager.getDocProduct(docId); const features = await dbManager.getDocFeatures(docId);
return product?.features.snapshotWindow; return features?.snapshotWindow;
}, },
); );

View File

@ -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. // Database fields that we permit in entities but don't want to cross the api.
const INTERNAL_FIELDS = new Set([ const INTERNAL_FIELDS = new Set([
'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId', 'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId',
'stripeSubscriptionId', 'stripePlanId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin', 'stripeSubscriptionId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
'authSubject', 'usage', 'createdBy' 'authSubject', 'usage', 'createdBy'
]); ]);

View File

@ -90,7 +90,7 @@ async function setupDb() {
}, { }, {
setUserAsOwner: false, setUserAsOwner: false,
useNewPlan: true, useNewPlan: true,
planType: TEAM_FREE_PLAN product: TEAM_FREE_PLAN
})); }));
} }
} }

View File

@ -10,6 +10,7 @@ import {Organization} from 'app/gen-server/entity/Organization';
import {Product} from 'app/gen-server/entity/Product'; import {Product} from 'app/gen-server/entity/Product';
import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager, UserChange} from 'app/gen-server/lib/HomeDBManager';
import {TestServer} from 'test/gen-server/apiUtils'; import {TestServer} from 'test/gen-server/apiUtils';
import {TEAM_FREE_PLAN} from 'app/common/Features';
const assert = chai.assert; const assert = chai.assert;
@ -920,6 +921,9 @@ describe('ApiServer', function() {
status: null, status: null,
externalId: null, externalId: null,
externalOptions: null, externalOptions: null,
features: null,
stripePlanId: null,
paymentLink: null,
}, },
}); });
assert.isNotNull(org.updatedAt); assert.isNotNull(org.updatedAt);
@ -2151,7 +2155,7 @@ describe('ApiServer', function() {
'best-friends-squad', false); 'best-friends-squad', false);
await dbManager.connection.query( await dbManager.connection.query(
'update billing_accounts set product_id = (select id from products where name = $1) where id = $2', '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`, { const resp = await axios.post(`${homeUrl}/api/orgs/${freeTeamOrgId}/workspaces`, {

View File

@ -64,8 +64,8 @@ describe('ApiSession', function() {
'createdAt', 'updatedAt', 'host']); 'createdAt', 'updatedAt', 'host']);
assert.deepEqual(resp.data.org.billingAccount, assert.deepEqual(resp.data.org.billingAccount,
{ id: 1, individual: false, inGoodStanding: true, status: null, { id: 1, individual: false, inGoodStanding: true, status: null,
externalId: null, externalOptions: null, externalId: null, externalOptions: null, paymentLink: null,
isManager: true, paid: false, isManager: true, paid: false, features: null, stripePlanId: null,
product: { id: 1, name: 'Free', features: {workspaces: true, vanityDomain: true} } }); product: { id: 1, name: 'Free', features: {workspaces: true, vanityDomain: true} } });
// Check that internally we have access to stripe ids. // Check that internally we have access to stripe ids.
@ -74,7 +74,7 @@ describe('ApiSession', function() {
assert.hasAllKeys(org2.data!.billingAccount, assert.hasAllKeys(org2.data!.billingAccount,
['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId', ['id', 'individual', 'inGoodStanding', 'status', 'stripeCustomerId',
'stripeSubscriptionId', 'stripePlanId', 'product', 'paid', 'isManager', '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() { it('GET /api/session/access/active returns orgErr when org is forbidden', async function() {

View File

@ -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 {ActivationPrefs1682636695021 as ActivationPrefs} from 'app/gen-server/migration/1682636695021-ActivationPrefs';
import {AssistantLimit1685343047786 as AssistantLimit} from 'app/gen-server/migration/1685343047786-AssistantLimit'; import {AssistantLimit1685343047786 as AssistantLimit} from 'app/gen-server/migration/1685343047786-AssistantLimit';
import {Shares1701557445716 as Shares} from 'app/gen-server/migration/1701557445716-Shares'; 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(); const home: HomeDBManager = new HomeDBManager();
@ -49,7 +50,7 @@ const migrations = [Initial, Login, PinDocs, UserPicture, DisplayEmail, DisplayE
CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs, CustomerIndex, ExtraIndexes, OrgHost, DocRemovedAt, Prefs,
ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart, ExternalBilling, DocOptions, Secret, UserOptions, GracePeriodStart,
DocumentUsage, Activations, UserConnectId, UserUUID, UserUniqueRefUUID, 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). // Assert that the "members" acl rule and group exist (or not).
function assertMembersGroup(org: Organization, exists: boolean) { function assertMembersGroup(org: Organization, exists: boolean) {

View File

@ -37,7 +37,7 @@ import {Document} from "app/gen-server/entity/Document";
import {Group} from "app/gen-server/entity/Group"; import {Group} from "app/gen-server/entity/Group";
import {Login} from "app/gen-server/entity/Login"; import {Login} from "app/gen-server/entity/Login";
import {Organization} from "app/gen-server/entity/Organization"; 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 {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace"; import {Workspace} from "app/gen-server/entity/Workspace";
import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/HomeDBManager'; 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']; const ACCESS_GROUPS = ['owners', 'editors', 'viewers', 'guests', 'members'];
export const testDailyApiLimitFeatures = {
...teamFreeFeatures,
baseMaxApiUnitsPerDocumentPerDay: 3,
};
const testProducts = [ const testProducts = [
...PRODUCTS, ...PRODUCTS,
{ {
@ -428,7 +435,11 @@ class Seed {
const ba = new BillingAccount(); const ba = new BillingAccount();
ba.individual = false; ba.individual = false;
const productName = org.product || 'Free'; 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; o.billingAccount = ba;
if (org.domain) { o.domain = org.domain; } if (org.domain) { o.domain = org.domain; }
if (org.host) { o.host = org.host; } if (org.host) { o.host = org.host; }

View File

@ -236,7 +236,7 @@ export async function getDocWorkerUrl(): Promise<string> {
} }
export async function waitForUrl(pattern: RegExp|string, waitMs: number = 2000) { 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); 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 * Click confirm on a user manager dialog. If clickRemove is set, then
* any extra modal that pops up will be accepted. Returns true unless * 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()); 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 } // end of namespace gristUtils
stackWrapOwnMethods(gristUtils); stackWrapOwnMethods(gristUtils);

View File

@ -5,7 +5,6 @@ import {SHARE_KEY_PREFIX} from 'app/common/gristUrls';
import {arrayRepeat} from 'app/common/gutil'; import {arrayRepeat} from 'app/common/gutil';
import {WebhookSummary} from 'app/common/Triggers'; import {WebhookSummary} from 'app/common/Triggers';
import {DocAPI, DocState, UserAPIImpl} from 'app/common/UserAPI'; 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 {AddOrUpdateRecord, Record as ApiRecord, ColumnsPut, RecordWithStringId} from 'app/plugin/DocApiTypes';
import {CellValue, GristObjCode} from 'app/plugin/GristData'; import {CellValue, GristObjCode} from 'app/plugin/GristData';
import { import {
@ -41,6 +40,7 @@ import {waitForIt} from 'test/server/wait';
import defaultsDeep = require('lodash/defaultsDeep'); import defaultsDeep = require('lodash/defaultsDeep');
import pick = require('lodash/pick'); import pick = require('lodash/pick');
import { getDatabase } from 'test/testUtils'; import { getDatabase } from 'test/testUtils';
import {testDailyApiLimitFeatures} from 'test/gen-server/seed';
const chimpy = configForUser('Chimpy'); const chimpy = configForUser('Chimpy');
const kiwi = configForUser('Kiwi'); const kiwi = configForUser('Kiwi');