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 {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 {
|
||||
|
@ -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); }
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user