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

View File

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

View File

@ -19,7 +19,7 @@ import {AsyncFlow, CancelledError, FlowRunner} from 'app/common/AsyncFlow';
import {delay} from 'app/common/delay';
import {OpenDocMode, OpenDocOptions, UserOverride} from 'app/common/DocListAPI';
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
import {Product} from 'app/common/Features';
import {Features, mergedFeatures, Product} from 'app/common/Features';
import {buildUrlId, IGristUrlState, parseUrlId, UrlIdParts} from 'app/common/gristUrls';
import {getReconnectTimeout} from 'app/common/gutil';
import {canEdit, isOwner} from 'app/common/roles';
@ -61,6 +61,10 @@ export interface DocPageModel {
* changes, or a doc usage message is received from the server.
*/
currentProduct: Observable<Product|null>;
/**
* Current features of the product
*/
currentFeatures: Observable<Features|null>;
// This block is to satisfy previous interface, but usable as this.currentDoc.get().id, etc.
currentDocId: Observable<string|undefined>;
@ -116,6 +120,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
* changes, or a doc usage message is received from the server.
*/
public readonly currentProduct = Observable.create<Product|null>(this, null);
public readonly currentFeatures: Computed<Features|null>;
public readonly currentUrlId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.urlId : undefined);
public readonly currentDocId = Computed.create(this, this.currentDoc, (use, doc) => doc ? doc.id : undefined);
@ -169,6 +174,14 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
constructor(private _appObj: App, public readonly appModel: AppModel, private _api: UserAPI = appModel.api) {
super();
this.currentFeatures = Computed.create(this, use => {
const product = use(this.currentProduct);
if (!product) { return null; }
const ba = use(this.currentOrg)?.billingAccount?.features ?? {};
const merged = mergedFeatures(product.features, ba);
return merged;
});
this.autoDispose(subscribe(urlState().state, (use, state) => {
const urlId = state.doc;
const urlOpenMode = state.mode;

View File

@ -382,7 +382,7 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings
// Check if active product allows just a single workspace.
function _isSingleWorkspaceMode(app: AppModel): boolean {
return app.currentFeatures.maxWorkspacesPerOrg === 1;
return app.currentFeatures?.maxWorkspacesPerOrg === 1;
}
// Returns a default view mode preference. We used to show 'list' for everyone. We now default to

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.
if (org.id === this._app.currentOrg?.id) {
const workspaces = this._app.currentOrg.billingAccount?.product.features.workspaces ?? true;
const numberAllowed = this._app.currentOrg.billingAccount?.product.features.maxWorkspacesPerOrg ?? 2;
const workspaces = this._app.currentFeatures?.workspaces ?? true;
const numberAllowed = this._app.currentFeatures?.maxWorkspacesPerOrg ?? 2;
return workspaces && numberAllowed > 1;
}
return true;

View File

@ -2,20 +2,33 @@ import {loadUserManager} from 'app/client/lib/imports';
import {AppModel} from 'app/client/models/AppModel';
import {FullUser, Organization, UserAPI} from 'app/common/UserAPI';
export interface ManageTeamUsersOptions {
org: Organization;
user: FullUser | null;
api: UserAPI;
onSave?: (personal: boolean) => Promise<unknown>;
}
// Opens the user-manager for the given org.
export async function manageTeamUsers(org: Organization, user: FullUser|null, api: UserAPI) {
export async function manageTeamUsers({org, user, api, onSave}: ManageTeamUsersOptions) {
(await loadUserManager()).showUserManagerModal(api, {
permissionData: api.getOrgAccess(org.id),
activeUser: user,
resourceType: 'organization',
resourceId: org.id,
resource: org,
onSave
});
}
export interface ManagePersonalUsersAppOptions {
app: AppModel;
onSave?: (personal: boolean) => Promise<unknown>;
}
// Opens the user-manager for the current org in the given AppModel.
export async function manageTeamUsersApp(app: AppModel) {
export async function manageTeamUsersApp({app, onSave}: ManagePersonalUsersAppOptions) {
if (app.currentOrg) {
return manageTeamUsers(app.currentOrg, app.currentValidUser, app.api);
return manageTeamUsers({org: app.currentOrg, user: app.currentValidUser, api: app.api, onSave});
}
}

View File

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

View File

@ -1,5 +1,5 @@
import {theme} from 'app/client/ui2018/cssVars';
import {DomArg, keyframes, styled} from 'grainjs';
import {DomArg, keyframes, Observable, observable, styled} from 'grainjs';
const rotate360 = keyframes(`
from { transform: rotate(45deg); }
@ -42,6 +42,21 @@ export function loadingDots(...args: DomArg<HTMLDivElement>[]) {
);
}
export function watchPromise<T extends (...args: any[]) => any>(fun: T): T & {busy: Observable<boolean>} {
const loading = observable(false);
const result = async (...args: any) => {
loading.set(true);
try {
return await fun(...args);
} finally {
if (!loading.isDisposed()) {
loading.set(false);
}
}
};
return Object.assign(result, {busy: loading}) as any;
}
const cssLoadingDotsContainer = styled('div', `
--dot-size: 10px;
display: inline-flex;

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {TEAM_FREE_PLAN} from 'app/common/Features';
import {FullUser} from 'app/common/LoginSessionAPI';
import {StringUnion} from 'app/common/StringUnion';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
@ -24,23 +25,22 @@ export type BillingTask = typeof BillingTask.type;
export interface IBillingPlan {
id: string; // the Stripe plan id
nickname: string;
currency: string; // lowercase three-letter ISO currency code
interval: string; // billing frequency - one of day, week, month or year
amount: number; // amount in cents charged at each interval
interval: 'day'|'week'|'month'|'year'; // billing frequency - one of day, week, month or year
// Merged metadata from price and product.
metadata: {
family?: string; // groups plans for filtering by GRIST_STRIPE_FAMILY env variable
isStandard: boolean; // indicates that the plan should be returned by the API to be offered.
supportAvailable: boolean;
gristProduct: string; // name of grist product that should be used with this plan.
unthrottledApi: boolean;
customSubdomain: boolean;
workspaces: boolean;
maxDocs?: number; // if given, limit of docs that can be created
maxUsersPerDoc?: number; // if given, limit of users each doc can be shared with
type: string; // type of the plan (either plan or limit for now)
minimumUnits?: number; // minimum number of units for the plan
gristLimit?: string; // type of the limit (for limit type plans)
};
trial_period_days: number|null; // Number of days in the trial period, or null if there is none.
amount: number; // amount in cents charged at each interval
trialPeriodDays: number|null; // Number of days in the trial period, or null if there is none.
product: string; // the Stripe product id.
features: string[]; // list of features that are available with this plan
active: boolean;
name: string; // the name of the product
}
export interface ILimitTier {
@ -95,16 +95,22 @@ export interface IBillingSubscription {
valueRemaining: number;
// The effective tax rate of the customer for the given address.
taxRate: number;
// The current number of seats paid for current billing period.
seatCount: number;
// The current number of users with whom the paid org is shared.
userCount: number;
// The next total in cents that Stripe is going to charge (includes tax and discount).
nextTotal: number;
// The next due date in milliseconds.
nextDueDate: number|null; // in milliseconds
// Discount information, if any.
discount: IBillingDiscount|null;
// Last plan we had a subscription for, if any.
lastPlanId: string|null;
// Whether there is a valid plan in effect.
isValidPlan: boolean;
// The time when the plan will be cancelled. (Not set when we are switching to a free plan)
cancelAt: number|null;
// A flag for when all is well with the user's subscription.
inGoodStanding: boolean;
// Whether there is a paying valid account (even on free plan). It this is set
@ -119,10 +125,19 @@ export interface IBillingSubscription {
// Stripe status, documented at https://stripe.com/docs/api/subscriptions/object#subscription_object-status
// such as "active", "trialing" (reflected in isInTrial), "incomplete", etc.
status?: string;
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
lastChargeTime?: number; // The time of the last charge attempt.
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
lastInvoiceOpen?: boolean; // Whether the last invoice is not paid but it can be.
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
lastChargeTime?: number; // The time of the last charge attempt.
limit?: ILimit|null;
balance?: number; // The balance of the account.
// Current product name. Even if not paid or not in good standing.
currentProductName?: string;
paymentLink?: string; // A link to the payment page for the current plan.
paymentOffer?: string; // Optional text to show for the offer.
paymentProduct?: string; // The product to show for the offer.
}
export interface ILimit {
@ -143,22 +158,72 @@ export interface FullBillingAccount extends BillingAccount {
managers: FullUser[];
}
export interface SummaryLine {
description: string;
quantity?: number|null;
amount: number;
}
// Info to show to the user when he changes the plan.
export interface ChangeSummary {
productName: string,
priceId: string,
interval: string,
quantity: number,
type: 'upgrade'|'downgrade',
regular: {
lines: SummaryLine[];
subTotal: number;
tax?: number;
total: number;
periodStart: number;
},
invoice?: {
lines: SummaryLine[];
subTotal: number;
tax?: number;
total: number;
appliedBalance: number;
amountDue: number;
dueDate: number;
}
}
export type UpgradeConfirmation = ChangeSummary|{checkoutUrl: string};
export interface PlanSelection {
product?: string; // grist product name
priceId?: string; // stripe id of the price
offerId?: string; // stripe id of the offer
count?: number; // number of units for the plan (suggested as it might be different).
}
export interface BillingAPI {
isDomainAvailable(domain: string): Promise<boolean>;
getPlans(): Promise<IBillingPlan[]>;
getPlans(plan?: PlanSelection): Promise<IBillingPlan[]>;
getSubscription(): Promise<IBillingSubscription>;
getBillingAccount(): Promise<FullBillingAccount>;
updateBillingManagers(delta: ManagerDelta): Promise<void>;
updateSettings(settings: IBillingOrgSettings): Promise<void>;
subscriptionStatus(planId: string): Promise<boolean>;
createFreeTeam(name: string, domain: string): Promise<string>;
createTeam(name: string, domain: string): Promise<string>;
upgrade(): Promise<string>;
createFreeTeam(name: string, domain: string): Promise<void>;
createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{
checkoutUrl?: string,
orgUrl?: string,
}>;
confirmChange(plan: PlanSelection): Promise<UpgradeConfirmation>;
changePlan(plan: PlanSelection): Promise<void>;
renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}>;
cancelCurrentPlan(): Promise<void>;
downgradePlan(planName: string): Promise<void>;
renewPlan(): string;
customerPortal(): string;
updateAssistantPlan(tier: number): Promise<void>;
changeProduct(product: string): Promise<void>;
attachSubscription(subscription: string): Promise<void>;
attachPayment(paymentLink: string): Promise<void>;
getPaymentLink(): Promise<UpgradeConfirmation>;
cancelPlanChange(): Promise<void>;
dontCancelPlan(): Promise<void>;
}
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
@ -172,8 +237,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
body: JSON.stringify({ domain })
});
}
public async getPlans(): Promise<IBillingPlan[]> {
return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'});
public async getPlans(plan?: PlanSelection): Promise<IBillingPlan[]> {
const url = new URL(`${this._url}/api/billing/plans`);
url.searchParams.set('product', plan?.product || '');
url.searchParams.set('priceId', plan?.priceId || '');
return this.requestJson(url.href, {
method: 'GET'
});
}
// Returns an IBillingSubscription
@ -191,13 +261,6 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
});
}
public async downgradePlan(planName: string): Promise<void> {
await this.request(`${this._url}/api/billing/downgrade-plan`, {
method: 'POST',
body: JSON.stringify({ planName })
});
}
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
await this.request(`${this._url}/api/billing/settings`, {
method: 'POST',
@ -212,43 +275,53 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
});
}
public async createFreeTeam(name: string, domain: string): Promise<string> {
const data = await this.requestJson(`${this._url}/api/billing/team-free`, {
method: 'POST',
body: JSON.stringify({
domain,
name
})
});
return data.orgUrl;
}
public async createTeam(name: string, domain: string): Promise<string> {
public async createTeam(name: string, domain: string, plan: {
product?: string, priceId?: string, count?: number
}, next?: string): Promise<{
checkoutUrl?: string,
orgUrl?: string,
}> {
const data = await this.requestJson(`${this._url}/api/billing/team`, {
method: 'POST',
body: JSON.stringify({
domain,
name,
planType: 'team',
next: window.location.href
...plan,
next
})
});
return data.checkoutUrl;
return data;
}
public async upgrade(): Promise<string> {
const data = await this.requestJson(`${this._url}/api/billing/upgrade`, {
method: 'POST',
public async createFreeTeam(name: string, domain: string): Promise<void> {
await this.createTeam(name, domain, {
product: TEAM_FREE_PLAN,
});
}
public async changePlan(plan: PlanSelection): Promise<void> {
await this.requestJson(`${this._url}/api/billing/change-plan`, {
method: 'POST',
body: JSON.stringify(plan)
});
}
public async confirmChange(plan: PlanSelection): Promise<ChangeSummary|{checkoutUrl: string}> {
return this.requestJson(`${this._url}/api/billing/confirm-change`, {
method: 'POST',
body: JSON.stringify(plan)
});
return data.checkoutUrl;
}
public customerPortal(): string {
return `${this._url}/api/billing/customer-portal`;
}
public renewPlan(): string {
return `${this._url}/api/billing/renew`;
public renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}> {
return this.requestJson(`${this._url}/api/billing/renew`, {
method: 'POST',
body: JSON.stringify(plan)
});
}
public async updateAssistantPlan(tier: number): Promise<void> {
@ -269,6 +342,39 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
return data.active;
}
public async changeProduct(product: string): Promise<void> {
await this.request(`${this._url}/api/billing/change-product`, {
method: 'POST',
body: JSON.stringify({ product })
});
}
public async attachSubscription(subscriptionId: string): Promise<void> {
await this.request(`${this._url}/api/billing/attach-subscription`, {
method: 'POST',
body: JSON.stringify({ subscriptionId })
});
}
public async attachPayment(paymentLink: string): Promise<void> {
await this.request(`${this._url}/api/billing/attach-payment`, {
method: 'POST',
body: JSON.stringify({ paymentLink })
});
}
public async getPaymentLink(): Promise<{checkoutUrl: string}> {
return await this.requestJson(`${this._url}/api/billing/payment-link`, {method: 'GET'});
}
public async cancelPlanChange(): Promise<void> {
await this.request(`${this._url}/api/billing/cancel-plan-change`, {method: 'POST'});
}
public async dontCancelPlan(): Promise<void> {
await this.request(`${this._url}/api/billing/dont-cancel-plan`, {method: 'POST'});
}
private get _url(): string {
return addCurrentOrgToPath(this._homeUrl);
}

52
app/common/Features-ti.ts Normal file
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 {
count: number;
unit: 'days' | 'month' | 'year';
@ -63,6 +67,70 @@ export interface Features {
// unbound limit. This is total limit, not per month or per day, it is used as a seed
// value for the limits table. To create a per-month limit, there must be a separate
// task that resets the usage in the limits table.
minimumUnits?: number; // Minimum number of units for the plan. Default no minimum.
}
/**
* Returns a merged set of features, combining the features of the given objects.
* If all objects are null, returns null.
*/
export function mergedFeatures(...features: (Features|null)[]): Features|null {
const filledIn = features.filter(Boolean) as Features[];
if (!filledIn.length) { return null; }
return filledIn.reduce((acc: Features, f) => defaultsDeep(acc, f), {});
}
/**
* Other meta values stored in Stripe Price or Product metadata.
*/
export interface StripeMetaValues {
isStandard?: boolean;
gristProduct?: string;
gristLimit?: string;
family?: string;
trialPeriodDays?: number;
}
export const FeaturesChecker = createCheckers(Checkers).Features as CheckerT<Features>;
export const StripeMetaValuesChecker = createCheckers(Checkers).StripeMetaValues as CheckerT<StripeMetaValues>;
/**
* Method takes arbitrary record and returns Features object, trimming any unknown fields.
* It mutates the input record.
*/
export function parseStripeFeatures(record: Record<string, any>): Features {
// Stripe metadata can contain many more values that we don't care about, so we just
// filter out the ones we do care about.
const validProps = new Set(FeaturesTi.props.map(p => p.name));
for (const key in record) {
// If this is unknown property, remove it.
if (!validProps.has(key)) {
delete record[key];
continue;
}
const value = record[key];
const tester = FeaturesChecker.getProp(key);
// If the top level property is invalid, just remove it.
if (!tester.strictTest(value)) {
// There is an exception for 1 and 0, if the target type is boolean.
switch (value) {
case 1:
record[key] = true;
break;
case 0:
record[key] = false;
break;
}
// Test one more time, if it is still invalid, remove it.
if (!tester.strictTest(record[key])) {
delete record[key];
}
}
}
return record;
}
// Check whether it is possible to add members at the org level. There's no flag
@ -73,22 +141,44 @@ export function canAddOrgMembers(features: Features): boolean {
return features.maxWorkspacesPerOrg !== 1;
}
export const PERSONAL_LEGACY_PLAN = 'starter';
// Grist is aware only about those plans.
// Those plans are synchronized with database only if they don't exists currently.
export const PERSONAL_FREE_PLAN = 'personalFree';
export const TEAM_FREE_PLAN = 'teamFree';
// This is a plan for suspended users.
export const SUSPENDED_PLAN = 'suspended';
// This is virtual plan for anonymous users.
export const ANONYMOUS_PLAN = 'anonymous';
// This is free plan. Grist doesn't offer a way to create it using API, but
// it can be configured as a substitute for any other plan using environment variables (like DEFAULT_TEAM_PLAN)
export const FREE_PLAN = 'Free';
// This is a plan for temporary org, before assigning a real plan.
export const STUB_PLAN = 'stub';
// Legacy free personal plan, which is not available anymore or created in new instances, but used
// here for displaying purposes and in tests.
export const PERSONAL_LEGACY_PLAN = 'starter';
// Pro plan for team sites (first tier). It is generally read from Stripe, but we use it in tests, so
// by default all installation have it. When Stripe updates it, it will be synchronized with Grist.
export const TEAM_PLAN = 'team';
export const displayPlanName: { [key: string]: string } = {
[PERSONAL_LEGACY_PLAN]: 'Free Personal (Legacy)',
[PERSONAL_FREE_PLAN]: 'Free Personal',
[TEAM_FREE_PLAN]: 'Team Free',
[SUSPENDED_PLAN]: 'Suspended',
[ANONYMOUS_PLAN]: 'Anonymous',
[FREE_PLAN]: 'Free',
[TEAM_PLAN]: 'Pro'
} as const;
// Returns true if `planName` is for a personal product.
export function isPersonalPlan(planName: string): boolean {
return isFreePersonalPlan(planName);
// Returns true if `planName` is for a legacy product.
export function isLegacyPlan(planName: string): boolean {
return planName === PERSONAL_LEGACY_PLAN;
}
// Returns true if `planName` is for a free personal product.
@ -96,32 +186,38 @@ export function isFreePersonalPlan(planName: string): boolean {
return [PERSONAL_LEGACY_PLAN, PERSONAL_FREE_PLAN].includes(planName);
}
// Returns true if `planName` is for a legacy product.
export function isLegacyPlan(planName: string): boolean {
return isFreeLegacyPlan(planName);
}
// Returns true if `planName` is for a free legacy product.
export function isFreeLegacyPlan(planName: string): boolean {
return [PERSONAL_LEGACY_PLAN].includes(planName);
}
// Returns true if `planName` is for a team product.
export function isTeamPlan(planName: string): boolean {
return !isPersonalPlan(planName);
}
// Returns true if `planName` is for a free team product.
export function isFreeTeamPlan(planName: string): boolean {
return [TEAM_FREE_PLAN].includes(planName);
}
// Returns true if `planName` is for a free product.
/**
* Actually all known plans don't require billing (which doesn't mean they are free actually, as it can
* be overridden by Stripe). There are also pro (team) and enterprise plans, which are billable, but they are
* read from Stripe.
*/
export function isFreePlan(planName: string): boolean {
return (
isFreePersonalPlan(planName) ||
isFreeTeamPlan(planName) ||
isFreeLegacyPlan(planName) ||
planName === 'Free'
);
switch (planName) {
case PERSONAL_LEGACY_PLAN:
case PERSONAL_FREE_PLAN:
case TEAM_FREE_PLAN:
case FREE_PLAN:
case ANONYMOUS_PLAN:
return true;
default:
return false;
}
}
/**
* Are the plan limits managed by Grist.
*/
export function isManagedPlan(planName: string): boolean {
switch (planName) {
case PERSONAL_LEGACY_PLAN:
case PERSONAL_FREE_PLAN:
case TEAM_FREE_PLAN:
case FREE_PLAN:
case SUSPENDED_PLAN:
case ANONYMOUS_PLAN:
case STUB_PLAN:
return true;
default:
return false;
}
}

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

View File

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

View File

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

View File

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

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';
import {nativeValues} from 'app/gen-server/lib/values';
import * as assert from 'assert';
@ -21,7 +28,8 @@ export const personalLegacyFeatures: Features = {
};
/**
* A summary of features used in 'team' plans.
* A summary of features used in 'team' plans. Grist ensures that this plan exists in the database, but it
* is treated as an external plan that came from Stripe, and is not modified by Grist.
*/
export const teamFeatures: Features = {
workspaces: true,
@ -71,16 +79,11 @@ export const teamFreeFeatures: Features = {
baseMaxAssistantCalls: 100,
};
export const testDailyApiLimitFeatures = {
...teamFreeFeatures,
baseMaxApiUnitsPerDocumentPerDay: 3,
};
/**
* A summary of features used in unrestricted grandfathered accounts, and also
* in some test settings.
*/
export const grandfatherFeatures: Features = {
export const freeAllFeatures: Features = {
workspaces: true,
vanityDomain: true,
};
@ -98,61 +101,44 @@ export const suspendedFeatures: Features = {
/**
*
* Products are a bundle of enabled features. Most products in
* Grist correspond to products in stripe. The correspondence is
* established by a gristProduct metadata field on stripe plans.
*
* In addition, there are the following products in Grist that don't
* exist in stripe:
* - The product named 'Free'. This is a product used for organizations
* created prior to the billing system being set up.
* - The product named 'stub'. This is product assigned to new
* organizations that should not be usable until a paid plan
* is set up for them.
*
* TODO: change capitalization of name of grandfather product.
*
* Products are a bundle of enabled features. Grist knows only
* about free products and creates them by default. Other products
* are created by the billing system (Stripe) and synchronized when used
* or via webhooks.
*/
export const PRODUCTS: IProduct[] = [
// This is a product for grandfathered accounts/orgs.
{
name: 'Free',
features: grandfatherFeatures,
},
// This is a product for newly created accounts/orgs.
{
name: 'stub',
features: {},
},
// This is a product for legacy personal accounts/orgs.
{
name: PERSONAL_LEGACY_PLAN,
features: personalLegacyFeatures,
},
{
name: 'professional', // deprecated, can be removed once no longer referred to in stripe.
features: teamFeatures,
name: PERSONAL_FREE_PLAN,
features: personalFreeFeatures, // those features are read from database, here are only as a reference.
},
{
name: TEAM_FREE_PLAN,
features: teamFreeFeatures,
},
// This is a product for a team site (used in tests mostly, as the real team plan is managed by Stripe).
{
name: TEAM_PLAN,
features: teamFeatures
},
// This is a product for a team site that is no longer in good standing, but isn't yet
// to be removed / deactivated entirely.
{
name: 'suspended',
features: suspendedFeatures
name: SUSPENDED_PLAN,
features: suspendedFeatures,
},
{
name: TEAM_FREE_PLAN,
features: teamFreeFeatures
name: FREE_PLAN,
features: freeAllFeatures,
},
// This is a product for newly created accounts/orgs.
{
name: PERSONAL_FREE_PLAN,
features: personalFreeFeatures,
},
name: STUB_PLAN,
features: {},
}
];
@ -161,7 +147,6 @@ export const PRODUCTS: IProduct[] = [
*/
export function getDefaultProductNames() {
const defaultProduct = process.env.GRIST_DEFAULT_PRODUCT;
// TODO: can be removed once new deal is released.
const personalFreePlan = PERSONAL_FREE_PLAN;
return {
// Personal site start off on a functional plan.
@ -218,6 +203,12 @@ export async function synchronizeProducts(
.map(p => [p.name, p]));
for (const product of desiredProducts.values()) {
if (existingProducts.has(product.name)) {
// Synchronize features only of known plans (team plan is not known).
if (!isManagedPlan(product.name)) {
continue;
}
const p = existingProducts.get(product.name)!;
try {
assert.deepStrictEqual(p.features, product.features);

View File

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

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,
useNewPlan: true,
planType: 'teamFree'
product: 'teamFree'
}));
});
}

View File

@ -1893,7 +1893,7 @@ export class DocWorkerApi {
// or to be wrongly rejected after upgrading.
const doc = (req as RequestWithLogin).docAuth!.cachedDoc!;
const max = doc.workspace.org.billingAccount?.product.features.baseMaxApiUnitsPerDocumentPerDay;
const max = doc.workspace.org.billingAccount?.getFeatures().baseMaxApiUnitsPerDocumentPerDay;
if (!max) {
// This doc has no associated product (happens to new unsaved docs)
// or the product has no API limit. Allow the request through.

View File

@ -1092,7 +1092,7 @@ export class FlexServer implements GristServer {
// If "welcomeNewUser" is ever added to billing pages, we'd need
// to avoid a redirect loop.
if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.product.features.vanityDomain) {
if (orgInfo.billingAccount.isManager && orgInfo.billingAccount.getFeatures().vanityDomain) {
const prefix = isOrgInPathOnly(req.hostname) ? `/o/${mreq.org}` : '';
return res.redirect(`${prefix}/billing/payment?billingTask=signUpLite`);
}

View File

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

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.
const INTERNAL_FIELDS = new Set([
'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId',
'stripeSubscriptionId', 'stripePlanId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
'stripeSubscriptionId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
'authSubject', 'usage', 'createdBy'
]);

View File

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

View File

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

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

View File

@ -37,7 +37,7 @@ import {Document} from "app/gen-server/entity/Document";
import {Group} from "app/gen-server/entity/Group";
import {Login} from "app/gen-server/entity/Login";
import {Organization} from "app/gen-server/entity/Organization";
import {Product, PRODUCTS, synchronizeProducts, testDailyApiLimitFeatures} from "app/gen-server/entity/Product";
import {Product, PRODUCTS, synchronizeProducts, teamFreeFeatures} from "app/gen-server/entity/Product";
import {User} from "app/gen-server/entity/User";
import {Workspace} from "app/gen-server/entity/Workspace";
import {EXAMPLE_WORKSPACE_NAME} from 'app/gen-server/lib/HomeDBManager';
@ -48,6 +48,13 @@ import * as fse from 'fs-extra';
const ACCESS_GROUPS = ['owners', 'editors', 'viewers', 'guests', 'members'];
export const testDailyApiLimitFeatures = {
...teamFreeFeatures,
baseMaxApiUnitsPerDocumentPerDay: 3,
};
const testProducts = [
...PRODUCTS,
{
@ -428,7 +435,11 @@ class Seed {
const ba = new BillingAccount();
ba.individual = false;
const productName = org.product || 'Free';
ba.product = (await Product.findOne({where: {name: productName}}))!;
const product = await Product.findOne({where: {name: productName}});
if (!product) {
throw new Error(`Product not found: ${productName}`);
}
ba.product = product;
o.billingAccount = ba;
if (org.domain) { o.domain = org.domain; }
if (org.host) { o.host = org.host; }

View File

@ -236,7 +236,7 @@ export async function getDocWorkerUrl(): Promise<string> {
}
export async function waitForUrl(pattern: RegExp|string, waitMs: number = 2000) {
await driver.wait(() => testCurrentUrl(pattern), waitMs);
await driver.wait(() => testCurrentUrl(pattern), waitMs, `waiting for url ${pattern}`);
}
@ -1823,6 +1823,34 @@ export async function editOrgAcls(): Promise<void> {
await driver.findWait('.test-um-members', 3000);
}
export async function addUser(email: string|string[], role?: 'Owner'|'Viewer'|'Editor'): Promise<void> {
await driver.findWait('.test-user-icon', 5000).click();
await driver.find('.test-dm-org-access').click();
await driver.findWait('.test-um-members', 500);
const orgInput = await driver.find('.test-um-member-new input');
const emails = Array.isArray(email) ? email : [email];
for(const e of emails) {
await orgInput.sendKeys(e, Key.ENTER);
if (role && role !== 'Viewer') {
await driver.findContentWait('.test-um-member', e, 1000).find('.test-um-member-role').click();
await driver.findContent('.test-um-role-option', role ?? 'Viewer').click();
}
}
await driver.find('.test-um-confirm').click();
await driver.wait(async () => !await driver.find('.test-um-members').isPresent(), 500);
}
export async function removeUser(email: string): Promise<void> {
await driver.findWait('.test-user-icon', 5000).click();
await driver.find('.test-dm-org-access').click();
await driver.findWait('.test-um-members', 500);
const kiwiRow = await driver.findContent('.test-um-member', email);
await kiwiRow.find('.test-um-member-delete').click();
await driver.find('.test-um-confirm').click();
await driver.wait(async () => !await driver.find('.test-um-members').isPresent(), 500);
}
/**
* Click confirm on a user manager dialog. If clickRemove is set, then
* any extra modal that pops up will be accepted. Returns true unless
@ -3746,6 +3774,23 @@ export function findValue(selector: string, value: string|RegExp) {
return new WebElementPromise(driver, inner());
}
export async function switchUser(email: string) {
await driver.findWait('.test-user-icon', 1000).click();
await driver.findContentWait('.test-usermenu-other-email', exactMatch(email), 1000).click();
await waitForServer();
}
/**
* Waits for the toast message with the given text to appear.
*/
export async function waitForAccessDenied() {
await waitToPass(async () => {
assert.equal(
await driver.findWait('.test-notifier-toast-message', 1000).getText(),
'access denied');
});
}
} // end of namespace gristUtils
stackWrapOwnMethods(gristUtils);

View File

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