(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

@@ -72,7 +72,7 @@ export function addOrg(
userId: number,
props: Partial<OrganizationProperties>,
options?: {
planType?: string,
product?: string,
billing?: BillingOptions,
}
): Promise<number> {

View File

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

View File

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

View File

@@ -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<Pick<BillingAccount,
'product' |
'stripeCustomerId' |
'stripeSubscriptionId' |
'stripePlanId' |
'externalId' |
'externalOptions' |
'inGoodStanding' |
'status'
'status' |
'paymentLink' |
'features'
>>;
/**
@@ -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<number> {
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<number> {
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<OrganizationProperties>,
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<QueryResult<number>> {
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<keyof BillingOptions> = [
'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<Product | undefined> {
return await this._connection.createQueryBuilder()
.select('product')
.from(Product, 'product')
.where('name = :name', {name})
.getOne() || undefined;
}
public async getDocFeatures(docId: string): Promise<Features | undefined> {
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<Product | undefined> {
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<Limit|null> {
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.

View File

@@ -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<any> {
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<any> {
await queryRunner.dropColumn('billing_accounts', 'features');
await queryRunner.dropColumn('billing_accounts', 'payment_link');
}
}