mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Customizable stripe plans.
Summary: - Reading plans from Stripe, and allowing Stripe to define custom plans. - Storing product features (aka limits) in Stripe, that override those in db. - Adding hierarchical data in Stripe. All features are defined at Product level but can be overwritten on Price levels. - New options for Support user to -- Override product for team site (if he is added as a billing manager) -- Override subscription and customer id for a team site -- Attach an "offer", an custom plan configured in stripe that a team site can use -- Enabling wire transfer for subscription by allowing subscription to be created without a payment method (which is customizable) Test Plan: Updated and new. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D4201
This commit is contained in:
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user