(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

@@ -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();
}