mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
4894631ba4
commit
f2e11a5329
@ -1,7 +1,7 @@
|
|||||||
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
|
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
|
||||||
import {urlState} from 'app/client/models/gristUrlState';
|
import {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {IFormData} from 'app/client/ui/BillingForm';
|
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 {IBillingCard, IBillingPlan, IBillingSubscription} from 'app/common/BillingAPI';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import {bundleChanges, Computed, Disposable, Observable} from 'grainjs';
|
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
|
// 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.
|
// management call based on currentTask, signupPlan and whether an address/tokenId was submitted.
|
||||||
submitPaymentPage(formData?: IFormData): Promise<void>;
|
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.
|
// Fetches the effective tax rate for the address in the given form.
|
||||||
fetchSignupTaxRate(formData: IFormData): Promise<void>;
|
fetchSignupTaxRate(formData: IFormData): Promise<void>;
|
||||||
// Fetches subscription data associated with the given org, if the pages are associated with an
|
// 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);
|
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> {
|
public async submitPaymentPage(formData: IFormData = {}): Promise<void> {
|
||||||
const task = this.currentTask.get();
|
const task = this.currentTask.get();
|
||||||
const planId = this.signupPlanId.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.token) { throw new Error('BillingPage _submit error: no card submitted'); }
|
||||||
if (!formData.address) { throw new Error('BillingPage _submit error: no address 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'); }
|
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) {
|
if (o && o.domain) {
|
||||||
await urlState().pushUrl({ org: o.domain, billing: 'billing', params: undefined });
|
await urlState().pushUrl({ org: o.domain, billing: 'billing', params: undefined });
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||||
import {reportError} from 'app/client/models/AppModel';
|
import {reportError} from 'app/client/models/AppModel';
|
||||||
|
import {BillingModel} from 'app/client/models/BillingModel';
|
||||||
import * as css from 'app/client/ui/BillingPageCss';
|
import * as css from 'app/client/ui/BillingPageCss';
|
||||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {IOption, select} from 'app/client/ui2018/menus';
|
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 {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {Organization} from 'app/common/UserAPI';
|
import {Organization} from 'app/common/UserAPI';
|
||||||
@ -20,10 +22,11 @@ const states = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export interface IFormData {
|
export interface IFormData {
|
||||||
address?: IBillingAddress;
|
address?: IFilledBillingAddress;
|
||||||
card?: IBillingCard;
|
card?: IBillingCard;
|
||||||
token?: string;
|
token?: string;
|
||||||
settings?: IBillingOrgSettings;
|
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
|
// Note that the card name is the only value that may be initialized, since the other card
|
||||||
// information is sensitive.
|
// information is sensitive.
|
||||||
card?: Partial<IBillingCard>;
|
card?: Partial<IBillingCard>;
|
||||||
|
coupon?: Partial<IBillingCoupon>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An object containing a function to check the validity of its observable value.
|
// 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 {
|
export class BillingForm extends Disposable {
|
||||||
private readonly _address: BillingAddressForm|null;
|
private readonly _address: BillingAddressForm|null;
|
||||||
|
private readonly _discount: BillingDiscountForm|null;
|
||||||
private readonly _payment: BillingPaymentForm|null;
|
private readonly _payment: BillingPaymentForm|null;
|
||||||
private readonly _settings: BillingSettingsForm|null;
|
private readonly _settings: BillingSettingsForm|null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
org: Organization|null,
|
org: Organization|null,
|
||||||
isDomainAvailable: (domain: string) => Promise<boolean>,
|
billingModel: BillingModel,
|
||||||
options: {payment: boolean, address: boolean, settings: boolean, domain: boolean},
|
options: {payment: boolean, address: boolean, settings: boolean, domain: boolean, discount: boolean},
|
||||||
autofill: IAutofill = {}
|
autofill: IAutofill = {}
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
@ -63,12 +68,17 @@ export class BillingForm extends Disposable {
|
|||||||
.reduce((acc, x) => acc + (x ? 1 : 0), 0);
|
.reduce((acc, x) => acc + (x ? 1 : 0), 0);
|
||||||
|
|
||||||
// Org settings form.
|
// Org settings form.
|
||||||
this._settings = options.settings ? new BillingSettingsForm(org, isDomainAvailable, {
|
this._settings = options.settings ? new BillingSettingsForm(billingModel, org, {
|
||||||
showHeader: count > 1,
|
showHeader: count > 1,
|
||||||
showDomain: options.domain,
|
showDomain: options.domain,
|
||||||
autofill: autofill.settings
|
autofill: autofill.settings
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
|
// Discount form.
|
||||||
|
this._discount = options.discount ? new BillingDiscountForm(billingModel, {
|
||||||
|
autofill: autofill.coupon
|
||||||
|
}) : null;
|
||||||
|
|
||||||
// Address form.
|
// Address form.
|
||||||
this._address = options.address ? new BillingAddressForm({
|
this._address = options.address ? new BillingAddressForm({
|
||||||
showHeader: count > 1,
|
showHeader: count > 1,
|
||||||
@ -85,6 +95,7 @@ export class BillingForm extends Disposable {
|
|||||||
public buildDom() {
|
public buildDom() {
|
||||||
return [
|
return [
|
||||||
this._settings ? this._settings.buildDom() : null,
|
this._settings ? this._settings.buildDom() : null,
|
||||||
|
this._discount ? this._discount.buildDom() : null,
|
||||||
this._address ? this._address.buildDom() : null,
|
this._address ? this._address.buildDom() : null,
|
||||||
this._payment ? this._payment.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 settings = this._settings ? await this._settings.getSettings() : undefined;
|
||||||
const address = this._address ? await this._address.getAddress() : undefined;
|
const address = this._address ? await this._address.getAddress() : undefined;
|
||||||
const cardInfo = this._payment ? await this._payment.getCardAndToken() : undefined;
|
const cardInfo = this._payment ? await this._payment.getCardAndToken() : undefined;
|
||||||
|
const coupon = this._discount ? await this._discount.getCoupon() : undefined;
|
||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
address,
|
address,
|
||||||
|
coupon,
|
||||||
token: cardInfo ? cardInfo.token : undefined,
|
token: cardInfo ? cardInfo.token : undefined,
|
||||||
card: cardInfo ? cardInfo.card : 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
|
// Throws if any value is invalid. Returns a customer address as accepted by the customer
|
||||||
// object in stripe.
|
// object in stripe.
|
||||||
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
|
// 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 {
|
try {
|
||||||
return {
|
return {
|
||||||
line1: await this._address1.get(),
|
line1: await this._address1.get(),
|
||||||
@ -371,8 +384,8 @@ class BillingSettingsForm extends BillingSubForm {
|
|||||||
this._options.showDomain ? d => this._verifyDomain(d) : () => undefined);
|
this._options.showDomain ? d => this._verifyDomain(d) : () => undefined);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly _billingModel: BillingModel,
|
||||||
private readonly _org: Organization|null,
|
private readonly _org: Organization|null,
|
||||||
private readonly _isDomainAvailable: (domain: string) => Promise<boolean>,
|
|
||||||
private readonly _options: {
|
private readonly _options: {
|
||||||
showHeader: boolean;
|
showHeader: boolean;
|
||||||
showDomain: boolean;
|
showDomain: boolean;
|
||||||
@ -444,11 +457,72 @@ class BillingSettingsForm extends BillingSubForm {
|
|||||||
// OK to retain current domain.
|
// OK to retain current domain.
|
||||||
if (domain === this._options.autofill?.domain) { return; }
|
if (domain === this._options.autofill?.domain) { return; }
|
||||||
checkSubdomainValidity(domain);
|
checkSubdomainValidity(domain);
|
||||||
const isAvailable = await this._isDomainAvailable(domain);
|
const isAvailable = await this._billingModel.isDomainAvailable(domain);
|
||||||
if (!isAvailable) { throw new Error('Domain is already taken.'); }
|
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) {
|
function checkFunc(func: (val: string) => boolean, message: string) {
|
||||||
return (val: string) => {
|
return (val: string) => {
|
||||||
if (!func(val)) { throw new Error(message); }
|
if (!func(val)) { throw new Error(message); }
|
||||||
|
@ -14,7 +14,8 @@ import {cssBreadcrumbs, cssBreadcrumbsLink, separator} from 'app/client/ui2018/b
|
|||||||
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {confirmModal} from 'app/client/ui2018/modals';
|
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 {capitalize} from 'app/common/gutil';
|
||||||
import {Organization} from 'app/common/UserAPI';
|
import {Organization} from 'app/common/UserAPI';
|
||||||
import {Disposable, dom, IAttrObj, IDomArgs, makeTestId, Observable} from 'grainjs';
|
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;
|
const planId = validPlan ? sub.activePlan.id : sub.lastPlanId;
|
||||||
// If on a "Tier" coupon, present information differently, emphasizing the coupon
|
// If on a "Tier" coupon, present information differently, emphasizing the coupon
|
||||||
// name and minimizing the plan.
|
// name and minimizing the plan.
|
||||||
const tier = sub.discountName && sub.discountName.includes(' Tier ');
|
const discountName = sub.discount && sub.discount.name;
|
||||||
const planName = tier ? sub.discountName! : sub.activePlan.nickname;
|
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;
|
const invoiceId = this._appModel.currentOrg?.billingAccount?.externalOptions?.invoiceId;
|
||||||
return [
|
return [
|
||||||
css.summaryFeatures(
|
css.summaryFeatures(
|
||||||
@ -172,12 +175,18 @@ export class BillingPage extends Disposable {
|
|||||||
moneyPlan.amount ? [
|
moneyPlan.amount ? [
|
||||||
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
|
makeSummaryFeature([`Your team site has `, `${sub.userCount}`,
|
||||||
` member${sub.userCount > 1 ? 's' : ''}`]),
|
` 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.
|
// Currently the subtotal is misleading and scary when tiers are in effect.
|
||||||
// In this case, for now, just report what will be invoiced.
|
// In this case, for now, just report what will be invoiced.
|
||||||
!tier ? makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `,
|
!tier ? makeSummaryFeature([`Your ${moneyPlan.interval}ly subtotal is `,
|
||||||
getPriceString(moneyPlan.amount * sub.userCount)]) : null,
|
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
|
// 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.
|
// match periodEnd for a trialing subscription, so we just use that.
|
||||||
sub.isInTrial ? makeSummaryFeature(['Your free trial ends on ', dateFmtFull(sub.periodEnd)]) : null,
|
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];
|
const pageText = taskActions[task];
|
||||||
// If there is an immediate charge required, require re-entering the card info.
|
// If there is an immediate charge required, require re-entering the card info.
|
||||||
// Show all forms on sign up.
|
// Show all forms on sign up.
|
||||||
this._form = new BillingForm(org, (...args) => this._model.isDomainAvailable(...args), {
|
this._form = new BillingForm(
|
||||||
|
org,
|
||||||
|
this._model,
|
||||||
|
{
|
||||||
payment: ['signUp', 'updatePlan', 'addCard', 'updateCard'].includes(task),
|
payment: ['signUp', 'updatePlan', 'addCard', 'updateCard'].includes(task),
|
||||||
|
discount: ['signUp'].includes(task),
|
||||||
address: ['signUp', 'updateAddress'].includes(task),
|
address: ['signUp', 'updateAddress'].includes(task),
|
||||||
settings: ['signUp', 'signUpLite', 'updateAddress', 'updateDomain'].includes(task),
|
settings: ['signUp', 'signUpLite', 'updateAddress', 'updateDomain'].includes(task),
|
||||||
domain: ['signUp', 'signUpLite', 'updateDomain'].includes(task)
|
domain: ['signUp', 'signUpLite', 'updateDomain'].includes(task)
|
||||||
}, { address: currentAddress, settings: currentSettings, card: this._formData.card });
|
},
|
||||||
|
{
|
||||||
|
address: currentAddress,
|
||||||
|
settings: currentSettings,
|
||||||
|
card: this._formData.card,
|
||||||
|
coupon: this._formData.coupon
|
||||||
|
}
|
||||||
|
);
|
||||||
return dom('div',
|
return dom('div',
|
||||||
dom.onDispose(() => {
|
dom.onDispose(() => {
|
||||||
if (this._form) {
|
if (this._form) {
|
||||||
@ -665,8 +685,6 @@ export class BillingPage extends Disposable {
|
|||||||
} else if (!sub) {
|
} else if (!sub) {
|
||||||
const planPriceStr = getPriceString(plan.amount);
|
const planPriceStr = getPriceString(plan.amount);
|
||||||
const subtotal = plan.amount * (stubSub?.userCount || 1);
|
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.
|
// 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.
|
// The server will allow the trial period only for fresh sign ups.
|
||||||
const trialSummary = (plan.trial_period_days && task === 'signUp') ?
|
const trialSummary = (plan.trial_period_days && task === 'signUp') ?
|
||||||
@ -674,8 +692,16 @@ export class BillingPage extends Disposable {
|
|||||||
return [
|
return [
|
||||||
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
|
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
|
||||||
dom.domComputed(this._showConfirmPage, confirmPage => {
|
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) {
|
if (confirmPage) {
|
||||||
return [
|
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]),
|
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `, subTotalPriceStr]),
|
||||||
// Note that on sign up, the number of users in the new org is always one.
|
// 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'])
|
trialSummary || makeSummaryFeature(['You will be charged ', totalPriceStr, ' to start'])
|
||||||
@ -692,15 +718,29 @@ export class BillingPage extends Disposable {
|
|||||||
];
|
];
|
||||||
} else if (plan.amount > sub.activePlan.amount) {
|
} else if (plan.amount > sub.activePlan.amount) {
|
||||||
const refund = sub.valueRemaining || 0;
|
const refund = sub.valueRemaining || 0;
|
||||||
|
const subtotal = plan.amount * sub.userCount;
|
||||||
|
const taxRate = sub.taxRate;
|
||||||
|
|
||||||
// User is upgrading their plan.
|
// User is upgrading their plan.
|
||||||
return [
|
return [
|
||||||
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
|
makeSummaryFeature(['You are changing to the ', plan.nickname, ' plan']),
|
||||||
makeSummaryFeature([`Your ${plan.interval}ly subtotal is `,
|
dom.domComputed(this._showConfirmPage, confirmPage => {
|
||||||
getPriceString(plan.amount * sub.userCount)]),
|
const subTotalOptions = {coupon: confirmPage ? this._formData.coupon : undefined};
|
||||||
makeSummaryFeature(['You will be charged ',
|
const subTotalPriceStr = getPriceString(subtotal, subTotalOptions);
|
||||||
getPriceString((plan.amount * sub.userCount) - refund, sub.taxRate), ' to start']),
|
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,
|
refund > 0 ? makeSummaryFeature(['Your charge is prorated based on the remaining plan time']) : null,
|
||||||
];
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
// User is cancelling their decision to downgrade their plan.
|
// User is cancelling their decision to downgrade their plan.
|
||||||
return [
|
return [
|
||||||
@ -799,7 +839,35 @@ function getSubscriptionProblem(sub: ISubscriptionModel) {
|
|||||||
return result.map(msg => makeSummaryFeature(msg, {isBad: true}));
|
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.
|
// TODO: Add functionality for other currencies.
|
||||||
return ((priceCents / 100) * (taxRate + 1)).toLocaleString('en-US', {
|
return ((priceCents / 100) * (taxRate + 1)).toLocaleString('en-US', {
|
||||||
style: "currency",
|
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:
|
* Make summary feature to include in:
|
||||||
* - Plan cards for describing features of the plan.
|
* - Plan cards for describing features of the plan.
|
||||||
|
@ -41,19 +41,44 @@ export interface IBillingPlan {
|
|||||||
// Stripe customer address information. Used to maintain the company address.
|
// Stripe customer address information. Used to maintain the company address.
|
||||||
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
|
// For reference: https://stripe.com/docs/api/customers/object#customer_object-address
|
||||||
export interface IBillingAddress {
|
export interface IBillingAddress {
|
||||||
line1: string;
|
line1: string|null;
|
||||||
line2?: string;
|
line2: string|null;
|
||||||
city?: string;
|
city: string|null;
|
||||||
state?: string;
|
state: string|null;
|
||||||
postal_code?: string;
|
postal_code: string|null;
|
||||||
country?: string;
|
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 {
|
export interface IBillingCard {
|
||||||
funding?: 'credit'|'debit'|'prepaid'|'unknown';
|
funding?: string|null;
|
||||||
brand?: string;
|
brand?: string|null;
|
||||||
country?: string; // uppercase two-letter ISO country code
|
country?: string|null; // uppercase two-letter ISO country code
|
||||||
last4?: string; // last 4 digits of the card number
|
last4?: string|null; // last 4 digits of the card number
|
||||||
name?: string|null;
|
name?: string|null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,8 +108,8 @@ export interface IBillingSubscription {
|
|||||||
userCount: number;
|
userCount: number;
|
||||||
// The next total in cents that Stripe is going to charge (includes tax and discount).
|
// The next total in cents that Stripe is going to charge (includes tax and discount).
|
||||||
nextTotal: number;
|
nextTotal: number;
|
||||||
// Name of the discount if any.
|
// Discount information, if any.
|
||||||
discountName: string|null;
|
discount: IBillingDiscount|null;
|
||||||
// Last plan we had a subscription for, if any.
|
// Last plan we had a subscription for, if any.
|
||||||
lastPlanId: string|null;
|
lastPlanId: string|null;
|
||||||
// Whether there is a valid plan in effect
|
// Whether there is a valid plan in effect
|
||||||
@ -111,6 +136,7 @@ export interface FullBillingAccount extends BillingAccount {
|
|||||||
|
|
||||||
export interface BillingAPI {
|
export interface BillingAPI {
|
||||||
isDomainAvailable(domain: string): Promise<boolean>;
|
isDomainAvailable(domain: string): Promise<boolean>;
|
||||||
|
getCoupon(promotionCode: string): Promise<IBillingCoupon>;
|
||||||
getTaxRate(address: IBillingAddress): Promise<number>;
|
getTaxRate(address: IBillingAddress): Promise<number>;
|
||||||
getPlans(): Promise<IBillingPlan[]>;
|
getPlans(): Promise<IBillingPlan[]>;
|
||||||
getSubscription(): Promise<IBillingSubscription>;
|
getSubscription(): Promise<IBillingSubscription>;
|
||||||
@ -118,7 +144,7 @@ export interface BillingAPI {
|
|||||||
// The signUp function takes the tokenId generated when card data is submitted to Stripe.
|
// 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
|
// See: https://stripe.com/docs/stripe-js/reference#stripe-create-token
|
||||||
signUp(planId: string, tokenId: string, address: IBillingAddress,
|
signUp(planId: string, tokenId: string, address: IBillingAddress,
|
||||||
settings: IBillingOrgSettings): Promise<OrganizationWithoutAccessInfo>;
|
settings: IBillingOrgSettings, promotionCode?: string): Promise<OrganizationWithoutAccessInfo>;
|
||||||
setCard(tokenId: string): Promise<void>;
|
setCard(tokenId: string): Promise<void>;
|
||||||
removeCard(): Promise<void>;
|
removeCard(): Promise<void>;
|
||||||
setSubscription(planId: string, options: {
|
setSubscription(planId: string, options: {
|
||||||
@ -143,6 +169,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
|||||||
return resp.json();
|
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> {
|
public async getTaxRate(address: IBillingAddress): Promise<number> {
|
||||||
const resp = await this.request(`${this._url}/api/billing/tax`, {
|
const resp = await this.request(`${this._url}/api/billing/tax`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -172,11 +205,12 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI {
|
|||||||
planId: string,
|
planId: string,
|
||||||
tokenId: string,
|
tokenId: string,
|
||||||
address: IBillingAddress,
|
address: IBillingAddress,
|
||||||
settings: IBillingOrgSettings
|
settings: IBillingOrgSettings,
|
||||||
|
promotionCode?: string,
|
||||||
): Promise<OrganizationWithoutAccessInfo> {
|
): Promise<OrganizationWithoutAccessInfo> {
|
||||||
const resp = await this.request(`${this._url}/api/billing/signup`, {
|
const resp = await this.request(`${this._url}/api/billing/signup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ tokenId, planId, address, settings })
|
body: JSON.stringify({ tokenId, planId, address, settings, promotionCode }),
|
||||||
});
|
});
|
||||||
const parsed = await resp.json();
|
const parsed = await resp.json();
|
||||||
return parsed.data;
|
return parsed.data;
|
||||||
|
Loading…
Reference in New Issue
Block a user