diff --git a/app/client/models/BillingModel.ts b/app/client/models/BillingModel.ts index 7ba3cc38..d9abcbbc 100644 --- a/app/client/models/BillingModel.ts +++ b/app/client/models/BillingModel.ts @@ -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; + // Fetches coupon information for a valid `discountCode`. + fetchSignupCoupon(discountCode: string): Promise; // Fetches the effective tax rate for the address in the given form. fetchSignupTaxRate(formData: IFormData): Promise; // 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 { + return await this._billingAPI.getCoupon(discountCode); + } + public async submitPaymentPage(formData: IFormData = {}): Promise { 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 { diff --git a/app/client/ui/BillingForm.ts b/app/client/ui/BillingForm.ts index dcec80f3..5d86f50b 100644 --- a/app/client/ui/BillingForm.ts +++ b/app/client/ui/BillingForm.ts @@ -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; + coupon?: Partial; } // An object containing a function to check the validity of its observable value. @@ -47,13 +51,14 @@ interface IValidated { 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, - 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 { + public async getAddress(): Promise { 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, 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 = createValidated(this, () => undefined); + + constructor( + private readonly _billingModel: BillingModel, + private readonly _options: { autofill?: Partial; } + ) { + 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); } diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index f2fb8f95..fb7e0ac5 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -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. diff --git a/app/common/BillingAPI.ts b/app/common/BillingAPI.ts index b73622b4..9c1c9ef0 100644 --- a/app/common/BillingAPI.ts +++ b/app/common/BillingAPI.ts @@ -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 = { [P in keyof T]: Required>; }; + +// Filled address info from the client. Fields can be blank strings. +export type IFilledBillingAddress = NonNullableProperties; + +// 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; + getCoupon(promotionCode: string): Promise; getTaxRate(address: IBillingAddress): Promise; getPlans(): Promise; getSubscription(): Promise; @@ -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; + settings: IBillingOrgSettings, promotionCode?: string): Promise; setCard(tokenId: string): Promise; removeCard(): Promise; setSubscription(planId: string, options: { @@ -143,6 +169,13 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI { return resp.json(); } + public async getCoupon(promotionCode: string): Promise { + const resp = await this.request(`${this._url}/api/billing/coupon/${promotionCode}`, { + method: 'GET', + }); + return resp.json(); + } + public async getTaxRate(address: IBillingAddress): Promise { 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 { 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;