(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

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