Ghost/ghost/tiers/lib/Tier.js
Fabien "egg" O'Carroll 253c30c37a Improved price data access and setting for Tier
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.
2022-10-25 09:01:39 +07:00

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;
}