(core) Customizable stripe plans.

Summary:
- Reading plans from Stripe, and allowing Stripe to define custom plans.
- Storing product features (aka limits) in Stripe, that override those in db.
- Adding hierarchical data in Stripe. All features are defined at Product level but can be overwritten on Price levels.
- New options for Support user to
-- Override product for team site (if he is added as a billing manager)
-- Override subscription and customer id for a team site
-- Attach an "offer", an custom plan configured in stripe that a team site can use
-- Enabling wire transfer for subscription by allowing subscription to be created without a payment method (which is customizable)

Test Plan: Updated and new.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D4201
This commit is contained in:
Jarosław Sadziński
2024-05-17 21:14:34 +02:00
parent ed9514bae0
commit 60423edc17
40 changed files with 720 additions and 248 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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