(core) Migrate to Stripe v8 + implement discount codes

Summary:
New plan signups now include a discount code field in
the signup form. If a valid discount code is entered, a
discount will be applied on the confirmation page.

Test Plan: Browser and server tests.

Reviewers: dsagal

Reviewed By: dsagal

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3076
This commit is contained in:
George Gevoian 2021-10-20 11:18:01 -07:00
parent 4894631ba4
commit f2e11a5329
4 changed files with 235 additions and 44 deletions

View File

@ -1,7 +1,7 @@
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {IFormData} from 'app/client/ui/BillingForm';
import {BillingAPI, BillingAPIImpl, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
import {BillingAPI, BillingAPIImpl, BillingSubPage, BillingTask, IBillingCoupon} from 'app/common/BillingAPI';
import {IBillingCard, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI';
import {FullUser} from 'app/common/LoginSessionAPI';
import {bundleChanges, Computed, Disposable, Observable} from 'grainjs';
@ -47,6 +47,8 @@ export interface BillingModel {
// Triggered when submit is clicked on the payment page. Performs the API billing account
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
submitPaymentPage(formData?: IFormData): Promise<void>;
// Fetches coupon information for a valid `discountCode`.
fetchSignupCoupon(discountCode: string): Promise<IBillingCoupon>;
// Fetches the effective tax rate for the address in the given form.
fetchSignupTaxRate(formData: IFormData): Promise<void>;
// Fetches subscription data associated with the given org, if the pages are associated with an
@ -133,6 +135,10 @@ export class BillingModelImpl extends Disposable implements BillingModel {
return this._billingAPI.isDomainAvailable(domain);
}
public async fetchSignupCoupon(discountCode: string): Promise<IBillingCoupon> {
return await this._billingAPI.getCoupon(discountCode);
}
public async submitPaymentPage(formData: IFormData = {}): Promise<void> {
const task = this.currentTask.get();
const planId = this.signupPlanId.get();
@ -145,7 +151,8 @@ export class BillingModelImpl extends Disposable implements BillingModel {
if (!formData.token) { throw new Error('BillingPage _submit error: no card submitted'); }
if (!formData.address) { throw new Error('BillingPage _submit error: no address submitted'); }
if (!formData.settings) { throw new Error('BillingPage _submit error: no settings submitted'); }
const o = await this._billingAPI.signUp(planId, formData.token, formData.address, formData.settings);
const {token, address, settings, coupon} = formData;
const o = await this._billingAPI.signUp(planId, token, address, settings, coupon?.promotion_code);
if (o && o.domain) {
await urlState().pushUrl({ org: o.domain, billing: 'billing', params: undefined });
} else {

View File

@ -1,9 +1,11 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {reportError} from 'app/client/models/AppModel';
import {BillingModel} from 'app/client/models/BillingModel';
import * as css from 'app/client/ui/BillingPageCss';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {IOption, select} from 'app/client/ui2018/menus';
import {IBillingAddress, IBillingCard, IBillingOrgSettings} from 'app/common/BillingAPI';
import {IBillingAddress, IBillingCard, IBillingCoupon, IBillingOrgSettings,
IFilledBillingAddress} from 'app/common/BillingAPI';
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
import * as roles from 'app/common/roles';
import {Organization} from 'app/common/UserAPI';
@ -20,10 +22,11 @@ const states = [
];
export interface IFormData {
address?: IBillingAddress;
address?: IFilledBillingAddress;
card?: IBillingCard;
token?: string;
settings?: IBillingOrgSettings;
coupon?: IBillingCoupon;
}
@ -34,6 +37,7 @@ interface IAutofill {
// Note that the card name is the only value that may be initialized, since the other card
// information is sensitive.
card?: Partial<IBillingCard>;
coupon?: Partial<IBillingCoupon>;
}
// An object containing a function to check the validity of its observable value.
@ -47,13 +51,14 @@ interface IValidated<T> {
export class BillingForm extends Disposable {
private readonly _address: BillingAddressForm|null;
private readonly _discount: BillingDiscountForm|null;
private readonly _payment: BillingPaymentForm|null;
private readonly _settings: BillingSettingsForm|null;
constructor(
org: Organization|null,
isDomainAvailable: (domain: string) => Promise<boolean>,
options: {payment: boolean, address: boolean, settings: boolean, domain: boolean},
billingModel: BillingModel,
options: {payment: boolean, address: boolean, settings: boolean, domain: boolean, discount: boolean},
autofill: IAutofill = {}
) {
super();
@ -63,12 +68,17 @@ export class BillingForm extends Disposable {
.reduce((acc, x) => acc + (x ? 1 : 0), 0);
// Org settings form.
this._settings = options.settings ? new BillingSettingsForm(org, isDomainAvailable, {
this._settings = options.settings ? new BillingSettingsForm(billingModel, org, {
showHeader: count > 1,
showDomain: options.domain,
autofill: autofill.settings
}) : null;
// Discount form.
this._discount = options.discount ? new BillingDiscountForm(billingModel, {
autofill: autofill.coupon
}) : null;
// Address form.
this._address = options.address ? new BillingAddressForm({
showHeader: count > 1,
@ -85,6 +95,7 @@ export class BillingForm extends Disposable {
public buildDom() {
return [
this._settings ? this._settings.buildDom() : null,
this._discount ? this._discount.buildDom() : null,
this._address ? this._address.buildDom() : null,
this._payment ? this._payment.buildDom() : null
];
@ -95,9 +106,11 @@ export class BillingForm extends Disposable {
const settings = this._settings ? await this._settings.getSettings() : undefined;
const address = this._address ? await this._address.getAddress() : undefined;
const cardInfo = this._payment ? await this._payment.getCardAndToken() : undefined;
const coupon = this._discount ? await this._discount.getCoupon() : undefined;
return {
settings,
address,
coupon,
token: cardInfo ? cardInfo.token : undefined,
card: cardInfo ? cardInfo.card : undefined
};
@ -344,7 +357,7 @@ class BillingAddressForm extends BillingSubForm {
// Throws if any value is invalid. Returns a customer address as accepted by the customer
// object in stripe.
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
public async getAddress(): Promise<IBillingAddress|undefined> {
public async getAddress(): Promise<IFilledBillingAddress|undefined> {
try {
return {
line1: await this._address1.get(),
@ -371,8 +384,8 @@ class BillingSettingsForm extends BillingSubForm {
this._options.showDomain ? d => this._verifyDomain(d) : () => undefined);
constructor(
private readonly _billingModel: BillingModel,
private readonly _org: Organization|null,
private readonly _isDomainAvailable: (domain: string) => Promise<boolean>,
private readonly _options: {
showHeader: boolean;
showDomain: boolean;
@ -444,11 +457,72 @@ class BillingSettingsForm extends BillingSubForm {
// OK to retain current domain.
if (domain === this._options.autofill?.domain) { return; }
checkSubdomainValidity(domain);
const isAvailable = await this._isDomainAvailable(domain);
const isAvailable = await this._billingModel.isDomainAvailable(domain);
if (!isAvailable) { throw new Error('Domain is already taken.'); }
}
}
/**
* Creates the billing discount form.
*/
class BillingDiscountForm extends BillingSubForm {
private _isExpanded = Observable.create(this, false);
private readonly _discountCode: IValidated<string> = createValidated(this, () => undefined);
constructor(
private readonly _billingModel: BillingModel,
private readonly _options: { autofill?: Partial<IBillingCoupon>; }
) {
super();
if (this._options.autofill) {
const { promotion_code } = this._options.autofill;
this._discountCode.value.set(promotion_code ?? '');
this._isExpanded.set(Boolean(promotion_code));
}
}
public buildDom() {
return dom.domComputed(this._isExpanded, isExpanded => [
!isExpanded ?
css.paymentBlock(
css.paymentRow(
css.billingText('Have a discount code?', testId('discount-code-question')),
css.billingTextBtn(
css.billingIcon('Settings'),
'Apply',
dom.on('click', () => this._isExpanded.set(true)),
testId('apply-discount-code')
)
)
) :
css.paymentBlock(
css.paymentRow(
css.paymentField(
css.paymentLabel('Discount Code'),
this.billingInput(this._discountCode, testId('discount-code')),
)
),
css.inputError(
dom.text(this.formError),
testId('discount-form-error')
)
)
]);
}
public async getCoupon() {
const discountCode = await this._discountCode.get();
if (discountCode.trim() === '') { return undefined; }
try {
return await this._billingModel.fetchSignupCoupon(discountCode);
} catch (e) {
this.formError.set('Invalid or expired discount code.');
throw e;
}
}
}
function checkFunc(func: (val: string) => boolean, message: string) {
return (val: string) => {
if (!func(val)) { throw new Error(message); }

View File

@ -14,7 +14,8 @@ import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/b
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal} from 'app/client/ui2018/modals';
import {BillingSubPage, BillingTask, IBillingAddress, IBillingCard, IBillingPlan} from 'app/common/BillingAPI';
import {BillingSubPage, BillingTask, IBillingAddress, IBillingCard, IBillingCoupon,
IBillingPlan} from 'app/common/BillingAPI';
import {capitalize} from 'app/common/gutil';
import {Organization} from 'app/common/UserAPI';
import {Disposable, dom, IAttrObj, IDomArgs, makeTestId, Observable} from 'grainjs';
@ -145,8 +146,10 @@ export class BillingPage extends Disposable {
const planId = validPlan ? sub.activePlan.id : sub.lastPlanId;
// If on a "Tier" coupon, present information differently, emphasizing the coupon
// name and minimizing the plan.
const tier = sub.discountName && sub.discountName.includes(' Tier ');
const planName = tier ? sub.discountName! : sub.activePlan.nickname;
const discountName = sub.discount && sub.discount.name;
const discountEnd = sub.discount && sub.discount.end_timestamp_ms;
const tier = discountName && discountName.includes(' Tier ');
const planName = tier ? discountName! : sub.activePlan.nickname;
const invoiceId = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
return [
css.summaryFeatures(
@ -172,12 +175,18 @@ export class BillingPage extends Disposable {
moneyPlan.amount ? [
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
` member${sub.userCount > 1 ? 's' : ''}`]),
tier ? this.buildAppSumoPlanNotes(sub.discountName!) : null,
tier ? this.buildAppSumoPlanNotes(discountName!) : null,
// Currently the subtotal is misleading and scary when tiers are in effect.
// In this case, for now, just report what will be invoiced.
!tier ? makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `,
getPriceString(moneyPlan.amount * sub.userCount)]) : null,
(sub.discountName && !tier) ? makeSummaryFeature([`You receive the `, sub.discountName]) : null,
(discountName && !tier) ?
makeSummaryFeature([
`You receive the `,
discountName,
...(discountEnd !== null ? [' (until ', dateFmtFull(discountEnd), ')'] : []),
]) :
null,
// When on a free trial, Stripe reports trialEnd time, but it seems to always
// match periodEnd for a trialing subscription, so we just use that.
sub.isInTrial ? makeSummaryFeature(['Your free trial ends on ', dateFmtFull(sub.periodEnd)]) : null,
@ -366,12 +375,23 @@ export class BillingPage extends Disposable {
const pageText = taskActions[task];
// If there is an immediate charge required, require re-entering the card info.
// Show all forms on sign up.
this._form = new BillingForm(org, (...args) => this._model.isDomainAvailable(...args), {
payment: ['signUp', 'updatePlan', 'addCard', 'updateCard'].includes(task),
address: ['signUp', 'updateAddress'].includes(task),
settings: ['signUp', 'signUpLite', 'updateAddress', 'updateDomain'].includes(task),
domain: ['signUp', 'signUpLite', 'updateDomain'].includes(task)
}, { address: currentAddress, settings: currentSettings, card: this._formData.card });
this._form = new BillingForm(
org,
this._model,
{
payment: ['signUp', 'updatePlan', 'addCard', 'updateCard'].includes(task),
discount: ['signUp'].includes(task),
address: ['signUp', 'updateAddress'].includes(task),
settings: ['signUp', 'signUpLite', 'updateAddress', 'updateDomain'].includes(task),
domain: ['signUp', 'signUpLite', 'updateDomain'].includes(task)
},
{
address: currentAddress,
settings: currentSettings,
card: this._formData.card,
coupon: this._formData.coupon
}
);
return dom('div',
dom.onDispose(() => {
if (this._form) {
@ -665,8 +685,6 @@ export class BillingPage extends Disposable {
} else if (!sub) {
const planPriceStr = getPriceString(plan.amount);
const subtotal = plan.amount * (stubSub?.userCount || 1);
const subTotalPriceStr = getPriceString(subtotal);
const totalPriceStr = getPriceString(subtotal, stubSub?.taxRate || 0);
// This is a new subscription, either a fresh sign ups, or renewal after cancellation.
// The server will allow the trial period only for fresh sign ups.
const trialSummary = (plan.trial_period_days && task === 'signUp') ?
@ -674,8 +692,16 @@ export class BillingPage extends Disposable {
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
dom.domComputed(this._showConfirmPage, confirmPage => {
const subTotalOptions = {coupon: confirmPage ? this._formData.coupon : undefined};
const subTotalPriceStr = getPriceString(subtotal, subTotalOptions);
const totalPriceStr = getPriceString(subtotal, {...subTotalOptions, taxRate: stubSub?.taxRate ?? 0});
if (confirmPage) {
return [
this._formData.coupon ?
makeSummaryFeature([
'You applied the ',
this._formData.coupon.name + ` (${getDiscountAmountString(this._formData.coupon)}) `,
]) : null,
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
// Note that on sign up, the number of users in the new org is always one.
trialSummary || makeSummaryFeature(['You will be charged ', totalPriceStr, ' to start'])
@ -692,14 +718,28 @@ export class BillingPage extends Disposable {
];
} else if (plan.amount > sub.activePlan.amount) {
const refund = sub.valueRemaining || 0;
const subtotal = plan.amount * sub.userCount;
const taxRate = sub.taxRate;
// User is upgrading their plan.
return [
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `,
getPriceString(plan.amount * sub.userCount)]),
makeSummaryFeature(['You will be charged ',
getPriceString((plan.amount * sub.userCount) - refund, sub.taxRate), ' to start']),
refund > 0 ? makeSummaryFeature(['Your charge is prorated based on the remaining plan time']) : null,
dom.domComputed(this._showConfirmPage, confirmPage => {
const subTotalOptions = {coupon: confirmPage ? this._formData.coupon : undefined};
const subTotalPriceStr = getPriceString(subtotal, subTotalOptions);
const startPriceStr = getPriceString(subtotal, {...subTotalOptions, taxRate, refund});
return [
confirmPage && this._formData.coupon ?
makeSummaryFeature([
'You applied the ',
this._formData.coupon.name + ` (${getDiscountAmountString(this._formData.coupon)}) `,
]) : null,
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
makeSummaryFeature(['You will be charged ', startPriceStr, ' to start']),
refund > 0 ? makeSummaryFeature(['Your charge is prorated based on the remaining plan time']) : null,
];
}),
];
} else {
// User is cancelling their decision to downgrade their plan.
@ -799,7 +839,35 @@ function getSubscriptionProblem(sub: ISubscriptionModel) {
return result.map(msg => makeSummaryFeature(msg, {isBad: true}));
}
function getPriceString(priceCents: number, taxRate: number = 0): string {
interface PriceOptions {
taxRate?: number;
coupon?: IBillingCoupon;
refund?: number;
}
const defaultPriceOptions: PriceOptions = {
taxRate: 0,
coupon: undefined,
refund: 0,
};
function getPriceString(priceCents: number, options = defaultPriceOptions): string {
const {taxRate = 0, coupon, refund} = options;
if (coupon) {
if (coupon.amount_off) {
priceCents -= coupon.amount_off;
} else if (coupon.percent_off) {
priceCents -= (priceCents * (coupon.percent_off / 100));
}
}
if (refund) {
priceCents -= refund;
}
// Make sure we never display negative prices.
priceCents = Math.max(0, priceCents);
// TODO: Add functionality for other currencies.
return ((priceCents / 100) * (taxRate + 1)).toLocaleString('en-US', {
style: "currency",
@ -808,6 +876,14 @@ function getPriceString(priceCents: number, taxRate: number = 0): string {
});
}
function getDiscountAmountString(coupon: IBillingCoupon): string {
if (coupon.amount_off !== null) {
return `${getPriceString(coupon.amount_off)} off`;
} else {
return `${coupon.percent_off!}% off`;
}
}
/**
* Make summary feature to include in:
* - Plan cards for describing features of the plan.

View File

@ -41,19 +41,44 @@ export interface IBillingPlan {
// 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;
line1: string|null;
line2: string|null;
city: string|null;
state: string|null;
postal_code: string|null;
country: string|null;
}
// Utility type that requires all properties to be non-nullish.
type NonNullableProperties<T> = { [P in keyof T]: Required<NonNullable<T[P]>>; };
// Filled address info from the client. Fields can be blank strings.
export type IFilledBillingAddress = NonNullableProperties<IBillingAddress>;
// 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 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
funding?: string|null;
brand?: string|null;
country?: string|null; // uppercase two-letter ISO country code
last4?: string|null; // last 4 digits of the card number
name?: string|null;
}
@ -83,8 +108,8 @@ export interface IBillingSubscription {
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;
// 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
@ -111,6 +136,7 @@ export interface FullBillingAccount extends BillingAccount {
export interface BillingAPI {
isDomainAvailable(domain: string): Promise<boolean>;
getCoupon(promotionCode: string): Promise<IBillingCoupon>;
getTaxRate(address: IBillingAddress): Promise<number>;
getPlans(): Promise<IBillingPlan[]>;
getSubscription(): Promise<IBillingSubscription>;
@ -118,7 +144,7 @@ export interface BillingAPI {
// 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>;
settings: IBillingOrgSettings, promotionCode?: string): Promise<OrganizationWithoutAccessInfo>;
setCard(tokenId: string): Promise<void>;
removeCard(): Promise<void>;
setSubscription(planId: string, options: {
@ -143,6 +169,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
return resp.json();
}
public async getCoupon(promotionCode: string): Promise<IBillingCoupon> {
const resp = await this.request(`${this._url}/api/billing/coupon/${promotionCode}`, {
method: 'GET',
});
return resp.json();
}
public async getTaxRate(address: IBillingAddress): Promise<number> {
const resp = await this.request(`${this._url}/api/billing/tax`, {
method: 'POST',
@ -172,11 +205,12 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
planId: string,
tokenId: string,
address: IBillingAddress,
settings: IBillingOrgSettings
settings: IBillingOrgSettings,
promotionCode?: string,
): Promise<OrganizationWithoutAccessInfo> {
const resp = await this.request(`${this._url}/api/billing/signup`, {
method: 'POST',
body: JSON.stringify({ tokenId, planId, address, settings })
body: JSON.stringify({ tokenId, planId, address, settings, promotionCode }),
});
const parsed = await resp.json();
return parsed.data;