mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Customizable stripe plans.
Summary: - Reading plans from Stripe, and allowing Stripe to define custom plans. - Storing product features (aka limits) in Stripe, that override those in db. - Adding hierarchical data in Stripe. All features are defined at Product level but can be overwritten on Price levels. - New options for Support user to -- Override product for team site (if he is added as a billing manager) -- Override subscription and customer id for a team site -- Attach an "offer", an custom plan configured in stripe that a team site can use -- Enabling wire transfer for subscription by allowing subscription to be created without a payment method (which is customizable) Test Plan: Updated and new. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4201
This commit is contained in:
parent
ed9514bae0
commit
60423edc17
@ -35,6 +35,7 @@ export class DocumentUsage extends Disposable {
|
|||||||
private readonly _currentDocUsage = this._docPageModel.currentDocUsage;
|
private readonly _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;
|
||||||
} = {}
|
} = {}
|
||||||
|
@ -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 ||
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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.
|
||||||
|
@ -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`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)")),
|
||||||
]),
|
]),
|
||||||
|
@ -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'),
|
||||||
|
@ -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"),
|
||||||
|
@ -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;
|
||||||
|
@ -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});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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'});
|
||||||
}
|
}
|
||||||
|
@ -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
52
app/common/Features-ti.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* This module was automatically generated by `ts-interface-builder`
|
||||||
|
*/
|
||||||
|
import * as t from "ts-interface-checker";
|
||||||
|
// tslint:disable:object-literal-key-quotes
|
||||||
|
|
||||||
|
export const SnapshotWindow = t.iface([], {
|
||||||
|
"count": "number",
|
||||||
|
"unit": t.union(t.lit('days'), t.lit('month'), t.lit('year')),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Product = t.iface([], {
|
||||||
|
"name": "string",
|
||||||
|
"features": "Features",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Features = t.iface([], {
|
||||||
|
"vanityDomain": t.opt("boolean"),
|
||||||
|
"workspaces": t.opt("boolean"),
|
||||||
|
"maxSharesPerDoc": t.opt("number"),
|
||||||
|
"maxSharesPerDocPerRole": t.opt(t.iface([], {
|
||||||
|
[t.indexKey]: "number",
|
||||||
|
})),
|
||||||
|
"maxSharesPerWorkspace": t.opt("number"),
|
||||||
|
"maxDocsPerOrg": t.opt("number"),
|
||||||
|
"maxWorkspacesPerOrg": t.opt("number"),
|
||||||
|
"readOnlyDocs": t.opt("boolean"),
|
||||||
|
"snapshotWindow": t.opt("SnapshotWindow"),
|
||||||
|
"baseMaxRowsPerDocument": t.opt("number"),
|
||||||
|
"baseMaxApiUnitsPerDocumentPerDay": t.opt("number"),
|
||||||
|
"baseMaxDataSizePerDocument": t.opt("number"),
|
||||||
|
"baseMaxAttachmentsBytesPerDocument": t.opt("number"),
|
||||||
|
"gracePeriodDays": t.opt("number"),
|
||||||
|
"baseMaxAssistantCalls": t.opt("number"),
|
||||||
|
"minimumUnits": t.opt("number"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StripeMetaValues = t.iface([], {
|
||||||
|
"isStandard": t.opt("boolean"),
|
||||||
|
"gristProduct": t.opt("string"),
|
||||||
|
"gristLimit": t.opt("string"),
|
||||||
|
"family": t.opt("string"),
|
||||||
|
"trialPeriodDays": t.opt("number"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportedTypeSuite: t.ITypeSuite = {
|
||||||
|
SnapshotWindow,
|
||||||
|
Product,
|
||||||
|
Features,
|
||||||
|
StripeMetaValues,
|
||||||
|
};
|
||||||
|
export default exportedTypeSuite;
|
@ -1,3 +1,7 @@
|
|||||||
|
import Checkers, {Features as FeaturesTi} from './Features-ti';
|
||||||
|
import {CheckerT, createCheckers} from 'ts-interface-checker';
|
||||||
|
import defaultsDeep from 'lodash/defaultsDeep';
|
||||||
|
|
||||||
export interface SnapshotWindow {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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> {
|
||||||
|
@ -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) ?? {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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.
|
||||||
|
23
app/gen-server/migration/1711557445716-Billing.ts
Normal file
23
app/gen-server/migration/1711557445716-Billing.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {nativeValues} from 'app/gen-server/lib/values';
|
||||||
|
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
|
||||||
|
|
||||||
|
export class Billing1711557445716 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.addColumn('billing_accounts', new TableColumn({
|
||||||
|
name: 'features',
|
||||||
|
type: nativeValues.jsonType,
|
||||||
|
isNullable: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await queryRunner.addColumn('billing_accounts', new TableColumn({
|
||||||
|
name: 'payment_link',
|
||||||
|
type: nativeValues.jsonType,
|
||||||
|
isNullable: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
await queryRunner.dropColumn('billing_accounts', 'features');
|
||||||
|
await queryRunner.dropColumn('billing_accounts', 'payment_link');
|
||||||
|
}
|
||||||
|
}
|
@ -176,7 +176,7 @@ export function addSiteCommand(program: commander.Command,
|
|||||||
}, {
|
}, {
|
||||||
setUserAsOwner: false,
|
setUserAsOwner: false,
|
||||||
useNewPlan: true,
|
useNewPlan: true,
|
||||||
planType: 'teamFree'
|
product: 'teamFree'
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -90,7 +90,7 @@ async function setupDb() {
|
|||||||
}, {
|
}, {
|
||||||
setUserAsOwner: false,
|
setUserAsOwner: false,
|
||||||
useNewPlan: true,
|
useNewPlan: true,
|
||||||
planType: TEAM_FREE_PLAN
|
product: TEAM_FREE_PLAN
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import {Organization} from 'app/gen-server/entity/Organization';
|
|||||||
import {Product} from 'app/gen-server/entity/Product';
|
import {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`, {
|
||||||
|
@ -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() {
|
||||||
|
@ -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) {
|
||||||
|
@ -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; }
|
||||||
|
@ -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);
|
||||||
|
@ -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');
|
||||||
|
Loading…
Reference in New Issue
Block a user