mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
60423edc17
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
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
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';
|
|
import {BillingAccount, ManagerDelta, OrganizationWithoutAccessInfo} from 'app/common/UserAPI';
|
|
|
|
export const BillingSubPage = StringUnion('payment', 'scheduled');
|
|
export type BillingSubPage = typeof BillingSubPage.type;
|
|
|
|
export const BillingPage = StringUnion(...BillingSubPage.values, 'billing');
|
|
export type BillingPage = typeof BillingPage.type;
|
|
|
|
// updateDomain - it is a subpage for billing page, to update domain name.
|
|
// The rest are for payment page:
|
|
// signUpLite - it is a subpage for payment, to finalize (complete) signup process
|
|
// and set domain and team name when they are not set yet (currently only from landing pages).
|
|
// signUp - it is landing page for new team sites (it doesn't ask for the name of the team)
|
|
export const BillingTask = StringUnion('signUpLite', 'updateDomain', 'signUp', 'cancelPlan', 'upgraded');
|
|
export type BillingTask = typeof BillingTask.type;
|
|
|
|
// Note that IBillingPlan includes selected fields from the Stripe plan object along with
|
|
// custom metadata fields that are present on plans we store in Stripe.
|
|
// For reference: https://stripe.com/docs/api/plans/object
|
|
export interface IBillingPlan {
|
|
id: string; // the Stripe plan id
|
|
nickname: string;
|
|
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.
|
|
gristProduct: string; // name of grist product that should be used with this plan.
|
|
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)
|
|
};
|
|
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 {
|
|
name?: string;
|
|
volume: number;
|
|
price: number;
|
|
flatFee: number;
|
|
type: string;
|
|
planId: string;
|
|
interval: string; // probably 'month'|'year';
|
|
}
|
|
|
|
// Utility type that requires all properties to be non-nullish.
|
|
// type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
|
|
|
|
// Stripe promotion code and coupon information. Used by client to apply signup discounts.
|
|
// For reference: https://stripe.com/docs/api/promotion_codes/object#promotion_code_object-coupon
|
|
export interface IBillingCoupon {
|
|
id: string;
|
|
promotion_code: string;
|
|
name: string|null;
|
|
percent_off: number|null;
|
|
amount_off: number|null;
|
|
}
|
|
|
|
// Stripe subscription discount information.
|
|
// For reference: https://stripe.com/docs/api/discounts/object
|
|
export interface IBillingDiscount {
|
|
name: string|null;
|
|
percent_off: number|null;
|
|
amount_off: number|null;
|
|
end_timestamp_ms: number|null;
|
|
}
|
|
|
|
|
|
export interface IBillingSubscription {
|
|
// All standard plan options.
|
|
plans: IBillingPlan[];
|
|
tiers: ILimitTier[];
|
|
// Index in the plans array of the plan currently in effect.
|
|
planIndex: number;
|
|
// Index in the plans array of the plan to be in effect after the current period end.
|
|
// Equal to the planIndex when the plan has not been downgraded or cancelled.
|
|
upcomingPlanIndex: number;
|
|
// Timestamp in milliseconds indicating when the current plan period ends.
|
|
// Null if the account is not signed up with Stripe.
|
|
periodEnd: number|null;
|
|
// Whether the subscription is in the trial period.
|
|
isInTrial: boolean;
|
|
// Value in cents remaining for the current subscription. This indicates the amount that
|
|
// will be discounted from a subscription upgrade.
|
|
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
|
|
// user needs to upgrade the plan using Stripe Customer portal. In not, we need to
|
|
// go though checkout process.
|
|
activeSubscription: boolean;
|
|
// Whether the plan is billable. Billable plans must be in Stripe.
|
|
billable: boolean;
|
|
// Whether we are waiting for upgrade to complete.
|
|
upgradingPlanIndex: number;
|
|
|
|
// 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.
|
|
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 {
|
|
limitValue: number;
|
|
currentUsage: number;
|
|
type: string; // Limit type, for now only assistant is supported.
|
|
price: number; // If this is 0, it means it is a free plan.
|
|
}
|
|
|
|
export interface IBillingOrgSettings {
|
|
name: string;
|
|
domain: string;
|
|
}
|
|
|
|
// Full description of billing account, including nested list of orgs and managers.
|
|
export interface FullBillingAccount extends BillingAccount {
|
|
orgs: OrganizationWithoutAccessInfo[];
|
|
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(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<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>;
|
|
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 {
|
|
constructor(private _homeUrl: string, options: IOptions = {}) {
|
|
super(options);
|
|
}
|
|
|
|
public async isDomainAvailable(domain: string): Promise<boolean> {
|
|
return this.requestJson(`${this._url}/api/billing/domain`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ domain })
|
|
});
|
|
}
|
|
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
|
|
public async getSubscription(): Promise<IBillingSubscription> {
|
|
return this.requestJson(`${this._url}/api/billing/subscription`, {method: 'GET'});
|
|
}
|
|
|
|
public async getBillingAccount(): Promise<FullBillingAccount> {
|
|
return this.requestJson(`${this._url}/api/billing`, {method: 'GET'});
|
|
}
|
|
|
|
public async cancelCurrentPlan() {
|
|
await this.request(`${this._url}/api/billing/cancel-plan`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
public async updateSettings(settings?: IBillingOrgSettings): Promise<void> {
|
|
await this.request(`${this._url}/api/billing/settings`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ settings })
|
|
});
|
|
}
|
|
|
|
public async updateBillingManagers(delta: ManagerDelta): Promise<void> {
|
|
await this.request(`${this._url}/api/billing/managers`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({delta})
|
|
});
|
|
}
|
|
|
|
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,
|
|
...plan,
|
|
next
|
|
})
|
|
});
|
|
return data;
|
|
}
|
|
|
|
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)
|
|
});
|
|
}
|
|
|
|
public customerPortal(): string {
|
|
return `${this._url}/api/billing/customer-portal`;
|
|
}
|
|
|
|
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> {
|
|
await this.request(`${this._url}/api/billing/upgrade-assistant`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ tier })
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Checks if current org has active subscription for a Stripe plan.
|
|
*/
|
|
public async subscriptionStatus(planId: string): Promise<boolean> {
|
|
const data = await this.requestJson(`${this._url}/api/billing/status`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({planId})
|
|
});
|
|
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);
|
|
}
|
|
}
|