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