253c30c37a
refs https://github.com/TryGhost/Team/issues/2078 We want to be able to easily get the price for a Tier based on the cadence for use when creating Stripe Prices. We also want to be able to set the pricing data to `null` for free Tiers, rather than erroring.
501 lines
13 KiB
JavaScript
501 lines
13 KiB
JavaScript
const ObjectID = require('bson-objectid').default;
|
|
const {ValidationError} = require('@tryghost/errors');
|
|
|
|
const TierActivatedEvent = require('./TierActivatedEvent');
|
|
const TierArchivedEvent = require('./TierArchivedEvent');
|
|
const TierCreatedEvent = require('./TierCreatedEvent');
|
|
const TierNameChangeEvent = require('./TierNameChangeEvent');
|
|
const TierPriceChangeEvent = require('./TierPriceChangeEvent');
|
|
|
|
module.exports = class Tier {
|
|
/** @type {BaseEvent[]} */
|
|
events = [];
|
|
|
|
/** @type {ObjectID} */
|
|
#id;
|
|
get id() {
|
|
return this.#id;
|
|
}
|
|
|
|
/** @type {string} */
|
|
#slug;
|
|
get slug() {
|
|
return this.#slug;
|
|
}
|
|
|
|
/** @type {string} */
|
|
#name;
|
|
get name() {
|
|
return this.#name;
|
|
}
|
|
set name(value) {
|
|
const newName = validateName(value);
|
|
if (newName === this.#name) {
|
|
return;
|
|
}
|
|
this.events.push(TierNameChangeEvent.create({tier: this}));
|
|
this.#name = newName;
|
|
}
|
|
|
|
/** @type {string[]} */
|
|
#benefits;
|
|
get benefits() {
|
|
return this.#benefits;
|
|
}
|
|
set benefits(value) {
|
|
this.#benefits = validateBenefits(value);
|
|
}
|
|
|
|
/** @type {string} */
|
|
#description;
|
|
get description() {
|
|
return this.#description;
|
|
}
|
|
set description(value) {
|
|
this.#description = validateDescription(value);
|
|
}
|
|
|
|
/** @type {string} */
|
|
#welcomePageURL;
|
|
get welcomePageURL() {
|
|
return this.#welcomePageURL;
|
|
}
|
|
set welcomePageURL(value) {
|
|
this.#welcomePageURL = validateWelcomePageURL(value);
|
|
}
|
|
|
|
/** @type {'active'|'archived'} */
|
|
#status;
|
|
get status() {
|
|
return this.#status;
|
|
}
|
|
set status(value) {
|
|
const newStatus = validateStatus(value);
|
|
if (newStatus === this.#status) {
|
|
return;
|
|
}
|
|
if (newStatus === 'active') {
|
|
this.events.push(TierActivatedEvent.create({tier: this}));
|
|
} else {
|
|
this.events.push(TierArchivedEvent.create({tier: this}));
|
|
}
|
|
this.#status = newStatus;
|
|
}
|
|
|
|
/** @type {'public'|'none'} */
|
|
#visibility;
|
|
get visibility() {
|
|
return this.#visibility;
|
|
}
|
|
set visibility(value) {
|
|
this.#visibility = validateVisibility(value);
|
|
}
|
|
|
|
/** @type {'paid'|'free'} */
|
|
#type;
|
|
get type() {
|
|
return this.#type;
|
|
}
|
|
|
|
/** @type {number|null} */
|
|
#trialDays;
|
|
get trialDays() {
|
|
return this.#trialDays;
|
|
}
|
|
set trialDays(value) {
|
|
this.#trialDays = validateTrialDays(value, this.#type);
|
|
}
|
|
|
|
/** @type {string|null} */
|
|
#currency;
|
|
get currency() {
|
|
return this.#currency;
|
|
}
|
|
set currency(value) {
|
|
this.#currency = validateCurrency(value, this.#type);
|
|
}
|
|
|
|
/**
|
|
* @param {'month'|'year'} cadence
|
|
*/
|
|
getPrice(cadence) {
|
|
if (cadence === 'month') {
|
|
return this.monthlyPrice;
|
|
}
|
|
if (cadence === 'year') {
|
|
return this.yearlyPrice;
|
|
}
|
|
throw new ValidationError({
|
|
message: 'Invalid cadence'
|
|
});
|
|
}
|
|
|
|
/** @type {number|null} */
|
|
#monthlyPrice;
|
|
get monthlyPrice() {
|
|
return this.#monthlyPrice;
|
|
}
|
|
set monthlyPrice(value) {
|
|
this.#monthlyPrice = validateMonthlyPrice(value, this.#type);
|
|
}
|
|
|
|
/** @type {number|null} */
|
|
#yearlyPrice;
|
|
get yearlyPrice() {
|
|
return this.#yearlyPrice;
|
|
}
|
|
set yearlyPrice(value) {
|
|
this.#yearlyPrice = validateYearlyPrice(value, this.#type);
|
|
}
|
|
|
|
updatePricing({currency, monthlyPrice, yearlyPrice}) {
|
|
if (this.#type !== 'paid' && (currency || monthlyPrice || yearlyPrice)) {
|
|
throw new ValidationError({
|
|
message: 'Cannot set pricing for free tiers'
|
|
});
|
|
}
|
|
|
|
const newCurrency = validateCurrency(currency, this.#type);
|
|
const newMonthlyPrice = validateMonthlyPrice(monthlyPrice, this.#type);
|
|
const newYearlyPrice = validateYearlyPrice(yearlyPrice, this.#type);
|
|
|
|
if (newCurrency === this.#currency && newMonthlyPrice === this.#monthlyPrice && newYearlyPrice === this.#yearlyPrice) {
|
|
return;
|
|
}
|
|
|
|
this.#currency = newCurrency;
|
|
this.#monthlyPrice = newMonthlyPrice;
|
|
this.#yearlyPrice = newYearlyPrice;
|
|
|
|
this.events.push(TierPriceChangeEvent.create({
|
|
tier: this
|
|
}));
|
|
}
|
|
|
|
/** @type {Date} */
|
|
#createdAt;
|
|
get createdAt() {
|
|
return this.#createdAt;
|
|
}
|
|
|
|
/** @type {Date|null} */
|
|
#updatedAt;
|
|
get updatedAt() {
|
|
return this.#updatedAt;
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
id: this.#id,
|
|
slug: this.#slug,
|
|
name: this.#name,
|
|
description: this.#description,
|
|
welcomePageURL: this.#welcomePageURL,
|
|
status: this.#status,
|
|
visibility: this.#visibility,
|
|
type: this.#type,
|
|
trialDays: this.#trialDays,
|
|
currency: this.#currency,
|
|
monthlyPrice: this.#monthlyPrice,
|
|
yearlyPrice: this.#yearlyPrice,
|
|
createdAt: this.#createdAt,
|
|
updatedAt: this.#updatedAt,
|
|
benefits: this.#benefits
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
constructor(data) {
|
|
this.#id = data.id;
|
|
this.#slug = data.slug;
|
|
this.#name = data.name;
|
|
this.#description = data.description;
|
|
this.#welcomePageURL = data.welcome_page_url;
|
|
this.#status = data.status;
|
|
this.#visibility = data.visibility;
|
|
this.#type = data.type;
|
|
this.#trialDays = data.trial_days;
|
|
this.#currency = data.currency;
|
|
this.#monthlyPrice = data.monthly_price;
|
|
this.#yearlyPrice = data.yearly_price;
|
|
this.#createdAt = data.created_at;
|
|
this.#updatedAt = data.updated_at;
|
|
this.#benefits = data.benefits;
|
|
}
|
|
|
|
/**
|
|
* @param {any} data
|
|
* @returns {Promise<Tier>}
|
|
*/
|
|
static async create(data) {
|
|
let id;
|
|
let isNew = false;
|
|
if (!data.id) {
|
|
isNew = true;
|
|
id = new ObjectID();
|
|
} else if (typeof data.id === 'string') {
|
|
id = ObjectID.createFromHexString(data.id);
|
|
} else if (data.id instanceof ObjectID) {
|
|
id = data.id;
|
|
} else {
|
|
throw new ValidationError({
|
|
message: 'Invalid ID provided for Tier'
|
|
});
|
|
}
|
|
|
|
let name = validateName(data.name);
|
|
|
|
let slug = validateSlug(data.slug);
|
|
let description = validateDescription(data.description);
|
|
let welcomePageURL = validateWelcomePageURL(data.welcomePageURL);
|
|
let status = validateStatus(data.status || 'active');
|
|
let visibility = validateVisibility(data.visibility || 'public');
|
|
let type = validateType(data.type || 'paid');
|
|
let currency = validateCurrency(data.currency || null, type);
|
|
let trialDays = validateTrialDays(data.trialDays || 0, type);
|
|
let monthlyPrice = validateMonthlyPrice(data.monthlyPrice || null, type);
|
|
let yearlyPrice = validateYearlyPrice(data.yearlyPrice || null , type);
|
|
let createdAt = validateCreatedAt(data.createdAt);
|
|
let updatedAt = validateUpdatedAt(data.updatedAt);
|
|
let benefits = validateBenefits(data.benefits);
|
|
|
|
const tier = new Tier({
|
|
id,
|
|
slug,
|
|
name,
|
|
description,
|
|
welcome_page_url: welcomePageURL,
|
|
status,
|
|
visibility,
|
|
type,
|
|
trial_days: trialDays,
|
|
currency,
|
|
monthly_price: monthlyPrice,
|
|
yearly_price: yearlyPrice,
|
|
created_at: createdAt,
|
|
updated_at: updatedAt,
|
|
benefits
|
|
});
|
|
|
|
if (isNew) {
|
|
tier.events.push(TierCreatedEvent.create({tier}));
|
|
}
|
|
|
|
return tier;
|
|
}
|
|
};
|
|
|
|
function validateSlug(value) {
|
|
if (!value || typeof value !== 'string' || value.length > 191) {
|
|
throw new ValidationError({
|
|
message: 'Tier slug must be a string with a maximum of 191 characters'
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateName(value) {
|
|
if (typeof value !== 'string') {
|
|
throw new ValidationError({
|
|
message: 'Tier name must be a string with a maximum of 191 characters'
|
|
});
|
|
}
|
|
|
|
if (value.length > 191) {
|
|
throw new ValidationError({
|
|
message: 'Tier name must be a string with a maximum of 191 characters'
|
|
});
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function validateWelcomePageURL(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (value === null || typeof value === 'string') {
|
|
return value;
|
|
}
|
|
throw new ValidationError({
|
|
message: 'Tier Welcome Page URL must be a string'
|
|
});
|
|
}
|
|
|
|
function validateDescription(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (typeof value !== 'string') {
|
|
throw new ValidationError({
|
|
message: 'Tier description must be a string with a maximum of 191 characters'
|
|
});
|
|
}
|
|
if (value.length > 191) {
|
|
throw new ValidationError({
|
|
message: 'Tier description must be a string with a maximum of 191 characters'
|
|
});
|
|
}
|
|
}
|
|
|
|
function validateStatus(value) {
|
|
if (value !== 'active' && value !== 'archived') {
|
|
throw new ValidationError({
|
|
message: 'Tier status must be either "active" or "archived"'
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateVisibility(value) {
|
|
if (value !== 'public' && value !== 'none') {
|
|
throw new ValidationError({
|
|
message: 'Tier visibility must be either "public" or "none"'
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateType(value) {
|
|
if (value !== 'paid' && value !== 'free') {
|
|
throw new ValidationError({
|
|
message: 'Tier type must be either "paid" or "free"'
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateTrialDays(value, type) {
|
|
if (type === 'free') {
|
|
if (value) {
|
|
throw new ValidationError({
|
|
message: 'Free Tiers cannot have a trial'
|
|
});
|
|
}
|
|
return 0;
|
|
}
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new ValidationError({
|
|
message: 'Tier trials must be an integer greater than 0'
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateCurrency(value, type) {
|
|
if (type === 'free') {
|
|
if (value !== null) {
|
|
throw new ValidationError({
|
|
message: 'Free Tiers cannot have a currency'
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
if (typeof value !== 'string') {
|
|
throw new ValidationError({
|
|
message: 'Tier currency must be a 3 letter ISO currency code'
|
|
});
|
|
}
|
|
if (value.length !== 3) {
|
|
throw new ValidationError({
|
|
message: 'Tier currency must be a 3 letter ISO currency code'
|
|
});
|
|
}
|
|
return value.toUpperCase();
|
|
}
|
|
|
|
function validateMonthlyPrice(value, type) {
|
|
if (type === 'free') {
|
|
if (value !== null) {
|
|
throw new ValidationError({
|
|
message: 'Free Tiers cannot have a monthly price'
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
if (!Number.isSafeInteger(value)) {
|
|
throw new ValidationError({
|
|
message: 'Tier prices must be an integer.'
|
|
});
|
|
}
|
|
if (value < 0) {
|
|
throw new ValidationError({
|
|
message: 'Tier prices must not be negative'
|
|
});
|
|
}
|
|
if (value > 9999999999) {
|
|
throw new ValidationError({
|
|
message: 'Tier prices may not exceed 999999.99'
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateYearlyPrice(value, type) {
|
|
if (type === 'free') {
|
|
if (value !== null) {
|
|
throw new ValidationError({
|
|
message: 'Free Tiers cannot have a yearly price'
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
if (!Number.isSafeInteger(value)) {
|
|
throw new ValidationError({
|
|
message: 'Tier prices must be an integer.'
|
|
});
|
|
}
|
|
if (value < 0) {
|
|
throw new ValidationError({
|
|
message: 'Tier prices must not be negative'
|
|
});
|
|
}
|
|
if (value > 9999999999) {
|
|
throw new ValidationError({
|
|
message: 'Tier prices may not exceed 999999.99'
|
|
});
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateCreatedAt(value) {
|
|
if (!value) {
|
|
return new Date();
|
|
}
|
|
if (value instanceof Date) {
|
|
return value;
|
|
}
|
|
throw new ValidationError({
|
|
message: 'Tier created_at must be a date'
|
|
});
|
|
}
|
|
|
|
function validateUpdatedAt(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (value instanceof Date) {
|
|
return value;
|
|
}
|
|
throw new ValidationError({
|
|
message: 'Tier created_at must be a date'
|
|
});
|
|
}
|
|
|
|
function validateBenefits(value) {
|
|
if (!value) {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) {
|
|
throw new ValidationError({
|
|
message: 'Tier benefits must be a list of strings'
|
|
});
|
|
}
|
|
return value;
|
|
}
|