gristlabs_grist-core/app/common/BillingAPI.ts
Paul Fitzpatrick 1af99e9567 (core) link AppSumo activations with stripe, and support upgrades/downgrades
Summary:
This links AppSumo sign-ups with Stripe subscriptions
and our billing pages. Different AppSumo tiers are supported by
different coupons on the standard plan. Configuration of this
is in stripe, and then cached in the database.

The front end is tweaked just enough to make completing a sign-up
possible. It is not yet friendly.

Not covered includes:
 * Streamlining landing page.
 * Making billing pages git clearer summaries of AppSumo states.
 * Making flow through Cognito as graceful as possible - default
   probably doesn't meet AppSumo requirements.
 * Disabling site on cancellation/refund.
 * Downgrades when more seats in use than lower tier allows.

Test Plan: api-level tests added. No front-end tests yet.

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2878
2021-06-24 10:18:42 -04:00

220 lines
8.4 KiB
TypeScript

import {BaseAPI, IOptions} from 'app/common/BaseAPI';
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', 'plans');
export type BillingSubPage = typeof BillingSubPage.type;
export const BillingPage = StringUnion(...BillingSubPage.values, 'billing');
export type BillingPage = typeof BillingPage.type;
export const BillingTask = StringUnion('signUp', 'signUpLite', 'updatePlan', 'addCard', 'updateCard', 'updateAddress');
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;
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
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
};
trial_period_days: number|null; // Number of days in the trial period, or null if there is none.
product: string; // the Stripe product id.
}
// Stripe customer address information. Used to maintain the company address.
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
export interface IBillingAddress {
line1: string;
line2?: string;
city?: string;
state?: string;
postal_code?: string;
country?: string;
}
export interface IBillingCard {
funding?: 'credit'|'debit'|'prepaid'|'unknown';
brand?: string;
country?: string; // uppercase two-letter ISO country code
last4?: string; // last 4 digits of the card number
name?: string|null;
}
export interface IBillingSubscription {
// All standard plan options.
plans: IBillingPlan[];
// 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 payment card, or null if none is attached.
card: IBillingCard|null;
// The company address.
address: IBillingAddress|null;
// The effective tax rate of the customer for the given address.
taxRate: 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;
// Name of the discount if any.
discountName: string|null;
// Last plan we had a subscription for, if any.
lastPlanId: string|null;
// Whether there is a valid plan in effect
isValidPlan: boolean;
// 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.
}
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 BillingAPI {
isDomainAvailable(domain: string): Promise<boolean>;
getTaxRate(address: IBillingAddress): Promise<number>;
getPlans(): Promise<IBillingPlan[]>;
getSubscription(): Promise<IBillingSubscription>;
getBillingAccount(): Promise<FullBillingAccount>;
// The signUp function takes the tokenId generated when card data is submitted to Stripe.
// See: https://stripe.com/docs/stripe-js/reference#stripe-create-token
signUp(planId: string, tokenId: string, address: IBillingAddress,
settings: IBillingOrgSettings): Promise<OrganizationWithoutAccessInfo>;
setCard(tokenId: string): Promise<void>;
removeCard(): Promise<void>;
setSubscription(planId: string, tokenId?: string): Promise<void>;
updateAddress(address?: IBillingAddress, settings?: IBillingOrgSettings): Promise<void>;
updateBillingManagers(delta: ManagerDelta): Promise<void>;
}
export class BillingAPIImpl extends BaseAPI implements BillingAPI {
constructor(private _homeUrl: string, options: IOptions = {}) {
super(options);
}
public async isDomainAvailable(domain: string): Promise<boolean> {
const resp = await this.request(`${this._url}/api/billing/domain`, {
method: 'POST',
body: JSON.stringify({ domain })
});
return resp.json();
}
public async getTaxRate(address: IBillingAddress): Promise<number> {
const resp = await this.request(`${this._url}/api/billing/tax`, {
method: 'POST',
body: JSON.stringify({ address })
});
return resp.json();
}
public async getPlans(): Promise<IBillingPlan[]> {
const resp = await this.request(`${this._url}/api/billing/plans`, {method: 'GET'});
return resp.json();
}
// Returns an IBillingSubscription
public async getSubscription(): Promise<IBillingSubscription> {
const resp = await this.request(`${this._url}/api/billing/subscription`, {method: 'GET'});
return resp.json();
}
public async getBillingAccount(): Promise<FullBillingAccount> {
const resp = await this.request(`${this._url}/api/billing`, {method: 'GET'});
return resp.json();
}
// Returns the new Stripe customerId.
public async signUp(
planId: string,
tokenId: string,
address: IBillingAddress,
settings: IBillingOrgSettings
): Promise<OrganizationWithoutAccessInfo> {
const resp = await this.request(`${this._url}/api/billing/signup`, {
method: 'POST',
body: JSON.stringify({ tokenId, planId, address, settings })
});
const parsed = await resp.json();
return parsed.data;
}
public async setSubscription(planId: string, tokenId?: string): Promise<void> {
await this.request(`${this._url}/api/billing/subscription`, {
method: 'POST',
body: JSON.stringify({ tokenId, planId })
});
}
public async removeSubscription(): Promise<void> {
await this.request(`${this._url}/api/billing/subscription`, {method: 'DELETE'});
}
public async setCard(tokenId: string): Promise<void> {
await this.request(`${this._url}/api/billing/card`, {
method: 'POST',
body: JSON.stringify({ tokenId })
});
}
public async removeCard(): Promise<void> {
await this.request(`${this._url}/api/billing/card`, {method: 'DELETE'});
}
public async updateAddress(address?: IBillingAddress, settings?: IBillingOrgSettings): Promise<void> {
await this.request(`${this._url}/api/billing/address`, {
method: 'POST',
body: JSON.stringify({ address, settings })
});
}
public async updateBillingManagers(delta: ManagerDelta): Promise<void> {
await this.request(`${this._url}/api/billing/managers`, {
method: 'PATCH',
body: JSON.stringify({delta})
});
}
private get _url(): string {
return addCurrentOrgToPath(this._homeUrl);
}
}