mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(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:
@@ -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
52
app/common/Features-ti.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user