(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

@@ -1,4 +1,5 @@
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {TEAM_FREE_PLAN} from 'app/common/Features';
import {FullUser} from 'app/common/LoginSessionAPI';
import {StringUnion} from 'app/common/StringUnion';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
@@ -24,23 +25,22 @@ export type BillingTask = typeof BillingTask.type;
export interface IBillingPlan {
id: string; // the Stripe plan id
nickname: string;
currency: string; // lowercase three-letter ISO currency code
interval: string; // billing frequency - one of day, week, month or year
amount: number; // amount in cents charged at each interval
interval: 'day'|'week'|'month'|'year'; // billing frequency - one of day, week, month or year
// Merged metadata from price and product.
metadata: {
family?: string; // groups plans for filtering by GRIST_STRIPE_FAMILY env variable
isStandard: boolean; // indicates that the plan should be returned by the API to be offered.
supportAvailable: boolean;
gristProduct: string; // name of grist product that should be used with this plan.
unthrottledApi: boolean;
customSubdomain: boolean;
workspaces: boolean;
maxDocs?: number; // if given, limit of docs that can be created
maxUsersPerDoc?: number; // if given, limit of users each doc can be shared with
type: string; // type of the plan (either plan or limit for now)
minimumUnits?: number; // minimum number of units for the plan
gristLimit?: string; // type of the limit (for limit type plans)
};
trial_period_days: number|null; // Number of days in the trial period, or null if there is none.
amount: number; // amount in cents charged at each interval
trialPeriodDays: number|null; // Number of days in the trial period, or null if there is none.
product: string; // the Stripe product id.
features: string[]; // list of features that are available with this plan
active: boolean;
name: string; // the name of the product
}
export interface ILimitTier {
@@ -95,16 +95,22 @@ export interface IBillingSubscription {
valueRemaining: number;
// The effective tax rate of the customer for the given address.
taxRate: number;
// The current number of seats paid for current billing period.
seatCount: number;
// The current number of users with whom the paid org is shared.
userCount: number;
// The next total in cents that Stripe is going to charge (includes tax and discount).
nextTotal: number;
// The next due date in milliseconds.
nextDueDate: number|null; // in milliseconds
// Discount information, if any.
discount: IBillingDiscount|null;
// Last plan we had a subscription for, if any.
lastPlanId: string|null;
// Whether there is a valid plan in effect.
isValidPlan: boolean;
// The time when the plan will be cancelled. (Not set when we are switching to a free plan)
cancelAt: number|null;
// A flag for when all is well with the user's subscription.
inGoodStanding: boolean;
// Whether there is a paying valid account (even on free plan). It this is set
@@ -119,10 +125,19 @@ export interface IBillingSubscription {
// Stripe status, documented at https://stripe.com/docs/api/subscriptions/object#subscription_object-status
// such as "active", "trialing" (reflected in isInTrial), "incomplete", etc.
status?: string;
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
lastChargeTime?: number; // The time of the last charge attempt.
lastInvoiceUrl?: string; // URL of the Stripe-hosted page with the last invoice.
lastInvoiceOpen?: boolean; // Whether the last invoice is not paid but it can be.
lastChargeError?: string; // The last charge error, if any, to show in case of a bad status.
lastChargeTime?: number; // The time of the last charge attempt.
limit?: ILimit|null;
balance?: number; // The balance of the account.
// Current product name. Even if not paid or not in good standing.
currentProductName?: string;
paymentLink?: string; // A link to the payment page for the current plan.
paymentOffer?: string; // Optional text to show for the offer.
paymentProduct?: string; // The product to show for the offer.
}
export interface ILimit {
@@ -143,22 +158,72 @@ export interface FullBillingAccount extends BillingAccount {
managers: FullUser[];
}
export interface SummaryLine {
description: string;
quantity?: number|null;
amount: number;
}
// Info to show to the user when he changes the plan.
export interface ChangeSummary {
productName: string,
priceId: string,
interval: string,
quantity: number,
type: 'upgrade'|'downgrade',
regular: {
lines: SummaryLine[];
subTotal: number;
tax?: number;
total: number;
periodStart: number;
},
invoice?: {
lines: SummaryLine[];
subTotal: number;
tax?: number;
total: number;
appliedBalance: number;
amountDue: number;
dueDate: number;
}
}
export type UpgradeConfirmation = ChangeSummary|{checkoutUrl: string};
export interface PlanSelection {
product?: string; // grist product name
priceId?: string; // stripe id of the price
offerId?: string; // stripe id of the offer
count?: number; // number of units for the plan (suggested as it might be different).
}
export interface BillingAPI {
isDomainAvailable(domain: string): Promise<boolean>;
getPlans(): Promise<IBillingPlan[]>;
getPlans(plan?: PlanSelection): Promise<IBillingPlan[]>;
getSubscription(): Promise<IBillingSubscription>;
getBillingAccount(): Promise<FullBillingAccount>;
updateBillingManagers(delta: ManagerDelta): Promise<void>;
updateSettings(settings: IBillingOrgSettings): Promise<void>;
subscriptionStatus(planId: string): Promise<boolean>;
createFreeTeam(name: string, domain: string): Promise<string>;
createTeam(name: string, domain: string): Promise<string>;
upgrade(): Promise<string>;
createFreeTeam(name: string, domain: string): Promise<void>;
createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{
checkoutUrl?: string,
orgUrl?: string,
}>;
confirmChange(plan: PlanSelection): Promise<UpgradeConfirmation>;
changePlan(plan: PlanSelection): Promise<void>;
renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}>;
cancelCurrentPlan(): Promise<void>;
downgradePlan(planName: string): Promise<void>;
renewPlan(): string;
customerPortal(): string;
updateAssistantPlan(tier: number): Promise<void>;
changeProduct(product: string): Promise<void>;
attachSubscription(subscription: string): Promise<void>;
attachPayment(paymentLink: string): Promise<void>;
getPaymentLink(): Promise<UpgradeConfirmation>;
cancelPlanChange(): Promise<void>;
dontCancelPlan(): Promise<void>;
}
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
@@ -172,8 +237,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
body: JSON.stringify({ domain })
});
}
public async getPlans(): Promise<IBillingPlan[]> {
return this.requestJson(`${this._url}/api/billing/plans`, {method: 'GET'});
public async getPlans(plan?: PlanSelection): Promise<IBillingPlan[]> {
const url = new URL(`${this._url}/api/billing/plans`);
url.searchParams.set('product', plan?.product || '');
url.searchParams.set('priceId', plan?.priceId || '');
return this.requestJson(url.href, {
method: 'GET'
});
}
// Returns an IBillingSubscription
@@ -191,13 +261,6 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
});
}
public async downgradePlan(planName: string): Promise<void> {
await this.request(`${this._url}/api/billing/downgrade-plan`, {
method: 'POST',
body: JSON.stringify({ planName })
});
}
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
await this.request(`${this._url}/api/billing/settings`, {
method: 'POST',
@@ -212,43 +275,53 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
});
}
public async createFreeTeam(name: string, domain: string): Promise<string> {
const data = await this.requestJson(`${this._url}/api/billing/team-free`, {
method: 'POST',
body: JSON.stringify({
domain,
name
})
});
return data.orgUrl;
}
public async createTeam(name: string, domain: string): Promise<string> {
public async createTeam(name: string, domain: string, plan: {
product?: string, priceId?: string, count?: number
}, next?: string): Promise<{
checkoutUrl?: string,
orgUrl?: string,
}> {
const data = await this.requestJson(`${this._url}/api/billing/team`, {
method: 'POST',
body: JSON.stringify({
domain,
name,
planType: 'team',
next: window.location.href
...plan,
next
})
});
return data.checkoutUrl;
return data;
}
public async upgrade(): Promise<string> {
const data = await this.requestJson(`${this._url}/api/billing/upgrade`, {
method: 'POST',
public async createFreeTeam(name: string, domain: string): Promise<void> {
await this.createTeam(name, domain, {
product: TEAM_FREE_PLAN,
});
}
public async changePlan(plan: PlanSelection): Promise<void> {
await this.requestJson(`${this._url}/api/billing/change-plan`, {
method: 'POST',
body: JSON.stringify(plan)
});
}
public async confirmChange(plan: PlanSelection): Promise<ChangeSummary|{checkoutUrl: string}> {
return this.requestJson(`${this._url}/api/billing/confirm-change`, {
method: 'POST',
body: JSON.stringify(plan)
});
return data.checkoutUrl;
}
public customerPortal(): string {
return `${this._url}/api/billing/customer-portal`;
}
public renewPlan(): string {
return `${this._url}/api/billing/renew`;
public renewPlan(plan: PlanSelection): Promise<{checkoutUrl: string}> {
return this.requestJson(`${this._url}/api/billing/renew`, {
method: 'POST',
body: JSON.stringify(plan)
});
}
public async updateAssistantPlan(tier: number): Promise<void> {
@@ -269,6 +342,39 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
return data.active;
}
public async changeProduct(product: string): Promise<void> {
await this.request(`${this._url}/api/billing/change-product`, {
method: 'POST',
body: JSON.stringify({ product })
});
}
public async attachSubscription(subscriptionId: string): Promise<void> {
await this.request(`${this._url}/api/billing/attach-subscription`, {
method: 'POST',
body: JSON.stringify({ subscriptionId })
});
}
public async attachPayment(paymentLink: string): Promise<void> {
await this.request(`${this._url}/api/billing/attach-payment`, {
method: 'POST',
body: JSON.stringify({ paymentLink })
});
}
public async getPaymentLink(): Promise<{checkoutUrl: string}> {
return await this.requestJson(`${this._url}/api/billing/payment-link`, {method: 'GET'});
}
public async cancelPlanChange(): Promise<void> {
await this.request(`${this._url}/api/billing/cancel-plan-change`, {method: 'POST'});
}
public async dontCancelPlan(): Promise<void> {
await this.request(`${this._url}/api/billing/dont-cancel-plan`, {method: 'POST'});
}
private get _url(): string {
return addCurrentOrgToPath(this._homeUrl);
}

52
app/common/Features-ti.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* This module was automatically generated by `ts-interface-builder`
*/
import * as t from "ts-interface-checker";
// tslint:disable:object-literal-key-quotes
export const SnapshotWindow = t.iface([], {
"count": "number",
"unit": t.union(t.lit('days'), t.lit('month'), t.lit('year')),
});
export const Product = t.iface([], {
"name": "string",
"features": "Features",
});
export const Features = t.iface([], {
"vanityDomain": t.opt("boolean"),
"workspaces": t.opt("boolean"),
"maxSharesPerDoc": t.opt("number"),
"maxSharesPerDocPerRole": t.opt(t.iface([], {
[t.indexKey]: "number",
})),
"maxSharesPerWorkspace": t.opt("number"),
"maxDocsPerOrg": t.opt("number"),
"maxWorkspacesPerOrg": t.opt("number"),
"readOnlyDocs": t.opt("boolean"),
"snapshotWindow": t.opt("SnapshotWindow"),
"baseMaxRowsPerDocument": t.opt("number"),
"baseMaxApiUnitsPerDocumentPerDay": t.opt("number"),
"baseMaxDataSizePerDocument": t.opt("number"),
"baseMaxAttachmentsBytesPerDocument": t.opt("number"),
"gracePeriodDays": t.opt("number"),
"baseMaxAssistantCalls": t.opt("number"),
"minimumUnits": t.opt("number"),
});
export const StripeMetaValues = t.iface([], {
"isStandard": t.opt("boolean"),
"gristProduct": t.opt("string"),
"gristLimit": t.opt("string"),
"family": t.opt("string"),
"trialPeriodDays": t.opt("number"),
});
const exportedTypeSuite: t.ITypeSuite = {
SnapshotWindow,
Product,
Features,
StripeMetaValues,
};
export default exportedTypeSuite;

View File

@@ -1,3 +1,7 @@
import Checkers, {Features as FeaturesTi} from './Features-ti';
import {CheckerT, createCheckers} from 'ts-interface-checker';
import defaultsDeep from 'lodash/defaultsDeep';
export interface SnapshotWindow {
count: number;
unit: 'days' | 'month' | 'year';
@@ -63,6 +67,70 @@ export interface Features {
// unbound limit. This is total limit, not per month or per day, it is used as a seed
// value for the limits table. To create a per-month limit, there must be a separate
// task that resets the usage in the limits table.
minimumUnits?: number; // Minimum number of units for the plan. Default no minimum.
}
/**
* Returns a merged set of features, combining the features of the given objects.
* If all objects are null, returns null.
*/
export function mergedFeatures(...features: (Features|null)[]): Features|null {
const filledIn = features.filter(Boolean) as Features[];
if (!filledIn.length) { return null; }
return filledIn.reduce((acc: Features, f) => defaultsDeep(acc, f), {});
}
/**
* Other meta values stored in Stripe Price or Product metadata.
*/
export interface StripeMetaValues {
isStandard?: boolean;
gristProduct?: string;
gristLimit?: string;
family?: string;
trialPeriodDays?: number;
}
export const FeaturesChecker = createCheckers(Checkers).Features as CheckerT<Features>;
export const StripeMetaValuesChecker = createCheckers(Checkers).StripeMetaValues as CheckerT<StripeMetaValues>;
/**
* Method takes arbitrary record and returns Features object, trimming any unknown fields.
* It mutates the input record.
*/
export function parseStripeFeatures(record: Record<string, any>): Features {
// Stripe metadata can contain many more values that we don't care about, so we just
// filter out the ones we do care about.
const validProps = new Set(FeaturesTi.props.map(p => p.name));
for (const key in record) {
// If this is unknown property, remove it.
if (!validProps.has(key)) {
delete record[key];
continue;
}
const value = record[key];
const tester = FeaturesChecker.getProp(key);
// If the top level property is invalid, just remove it.
if (!tester.strictTest(value)) {
// There is an exception for 1 and 0, if the target type is boolean.
switch (value) {
case 1:
record[key] = true;
break;
case 0:
record[key] = false;
break;
}
// Test one more time, if it is still invalid, remove it.
if (!tester.strictTest(record[key])) {
delete record[key];
}
}
}
return record;
}
// Check whether it is possible to add members at the org level. There's no flag
@@ -73,22 +141,44 @@ export function canAddOrgMembers(features: Features): boolean {
return features.maxWorkspacesPerOrg !== 1;
}
export const PERSONAL_LEGACY_PLAN = 'starter';
// Grist is aware only about those plans.
// Those plans are synchronized with database only if they don't exists currently.
export const PERSONAL_FREE_PLAN = 'personalFree';
export const TEAM_FREE_PLAN = 'teamFree';
// This is a plan for suspended users.
export const SUSPENDED_PLAN = 'suspended';
// This is virtual plan for anonymous users.
export const ANONYMOUS_PLAN = 'anonymous';
// This is free plan. Grist doesn't offer a way to create it using API, but
// it can be configured as a substitute for any other plan using environment variables (like DEFAULT_TEAM_PLAN)
export const FREE_PLAN = 'Free';
// This is a plan for temporary org, before assigning a real plan.
export const STUB_PLAN = 'stub';
// Legacy free personal plan, which is not available anymore or created in new instances, but used
// here for displaying purposes and in tests.
export const PERSONAL_LEGACY_PLAN = 'starter';
// Pro plan for team sites (first tier). It is generally read from Stripe, but we use it in tests, so
// by default all installation have it. When Stripe updates it, it will be synchronized with Grist.
export const TEAM_PLAN = 'team';
export const displayPlanName: { [key: string]: string } = {
[PERSONAL_LEGACY_PLAN]: 'Free Personal (Legacy)',
[PERSONAL_FREE_PLAN]: 'Free Personal',
[TEAM_FREE_PLAN]: 'Team Free',
[SUSPENDED_PLAN]: 'Suspended',
[ANONYMOUS_PLAN]: 'Anonymous',
[FREE_PLAN]: 'Free',
[TEAM_PLAN]: 'Pro'
} as const;
// Returns true if `planName` is for a personal product.
export function isPersonalPlan(planName: string): boolean {
return isFreePersonalPlan(planName);
// Returns true if `planName` is for a legacy product.
export function isLegacyPlan(planName: string): boolean {
return planName === PERSONAL_LEGACY_PLAN;
}
// Returns true if `planName` is for a free personal product.
@@ -96,32 +186,38 @@ export function isFreePersonalPlan(planName: string): boolean {
return [PERSONAL_LEGACY_PLAN, PERSONAL_FREE_PLAN].includes(planName);
}
// Returns true if `planName` is for a legacy product.
export function isLegacyPlan(planName: string): boolean {
return isFreeLegacyPlan(planName);
}
// Returns true if `planName` is for a free legacy product.
export function isFreeLegacyPlan(planName: string): boolean {
return [PERSONAL_LEGACY_PLAN].includes(planName);
}
// Returns true if `planName` is for a team product.
export function isTeamPlan(planName: string): boolean {
return !isPersonalPlan(planName);
}
// Returns true if `planName` is for a free team product.
export function isFreeTeamPlan(planName: string): boolean {
return [TEAM_FREE_PLAN].includes(planName);
}
// Returns true if `planName` is for a free product.
/**
* Actually all known plans don't require billing (which doesn't mean they are free actually, as it can
* be overridden by Stripe). There are also pro (team) and enterprise plans, which are billable, but they are
* read from Stripe.
*/
export function isFreePlan(planName: string): boolean {
return (
isFreePersonalPlan(planName) ||
isFreeTeamPlan(planName) ||
isFreeLegacyPlan(planName) ||
planName === 'Free'
);
switch (planName) {
case PERSONAL_LEGACY_PLAN:
case PERSONAL_FREE_PLAN:
case TEAM_FREE_PLAN:
case FREE_PLAN:
case ANONYMOUS_PLAN:
return true;
default:
return false;
}
}
/**
* Are the plan limits managed by Grist.
*/
export function isManagedPlan(planName: string): boolean {
switch (planName) {
case PERSONAL_LEGACY_PLAN:
case PERSONAL_FREE_PLAN:
case TEAM_FREE_PLAN:
case FREE_PLAN:
case SUSPENDED_PLAN:
case ANONYMOUS_PLAN:
case STUB_PLAN:
return true;
default:
return false;
}
}

View File

@@ -1,4 +1,4 @@
import { isTeamPlan, Product } from 'app/common/Features';
import { Features } from 'app/common/Features';
import { normalizeEmail } from 'app/common/emails';
import { PermissionData, PermissionDelta } from 'app/common/UserAPI';
@@ -37,11 +37,10 @@ export interface ShareAnnotatorOptions {
* current shares in place.
*/
export class ShareAnnotator {
private _features = this._product?.features ?? {};
private _supportEmail = this._options.supportEmail;
constructor(
private _product: Product|null,
private _features: Features|null,
private _state: PermissionData,
private _options: ShareAnnotatorOptions = {}
) {
@@ -52,9 +51,9 @@ export class ShareAnnotator {
}
public annotateChanges(change: PermissionDelta): ShareAnnotations {
const features = this._features;
const features = this._features ?? {};
const annotations: ShareAnnotations = {
hasTeam: !this._product || isTeamPlan(this._product.name),
hasTeam: !this._features || this._features.vanityDomain,
users: new Map(),
};
if (features.maxSharesPerDocPerRole || features.maxSharesPerWorkspace) {

View File

@@ -9,7 +9,7 @@ import {BulkColValues, TableColValues, TableRecordValue, TableRecordValues,
TableRecordValuesWithoutIds, UserAction} from 'app/common/DocActions';
import {DocCreationInfo, OpenDocMode} from 'app/common/DocListAPI';
import {OrgUsageSummary} from 'app/common/DocUsage';
import {Product} from 'app/common/Features';
import {Features, Product} from 'app/common/Features';
import {isClient} from 'app/common/gristUrls';
import {encodeQueryParams} from 'app/common/gutil';
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
@@ -75,8 +75,10 @@ export interface BillingAccount {
id: number;
individual: boolean;
product: Product;
stripePlanId: string; // Stripe price id.
isManager: boolean;
inGoodStanding: boolean;
features: Features;
externalOptions?: {
invoiceId?: string;
};

View File

@@ -134,8 +134,9 @@ export interface IGristUrlState {
docTour?: boolean;
manageUsers?: boolean;
createTeam?: boolean;
upgradeTeam?: boolean;
params?: {
billingPlan?: string;
billingPlan?: string; // priceId
planType?: string;
billingTask?: BillingTask;
embed?: boolean;
@@ -358,6 +359,8 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
url.hash = 'manage-users';
} else if (state.createTeam) {
url.hash = 'create-team';
} else if (state.upgradeTeam) {
url.hash = 'upgrade-team';
} else {
url.hash = '';
}
@@ -573,6 +576,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
state.docTour = hashMap.get('#') === 'repeat-doc-tour';
state.manageUsers = hashMap.get('#') === 'manage-users';
state.createTeam = hashMap.get('#') === 'create-team';
state.upgradeTeam = hashMap.get('#') === 'upgrade-team';
}
return state;
}