(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.