(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

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