Ghost/ghost/members-api/lib/repositories/product.js
Daniel Lockyer 54c143a1b4
Fixed optional syntax style for jsdoc
refs https://jsdoc.app/tags-param.html#optional-parameters-and-default-values

- using an equals sign in the type definition is part of the Google
  Closure syntax but we use the JSDoc syntax in all other places, and
  tsc detects the different syntax
- this commit standardizes the syntax ahead of enforcing a certain style
  down the line
2022-10-16 14:48:05 +07:00

655 lines
24 KiB
JavaScript

const {UpdateCollisionError, NotFoundError, MethodNotAllowedError, ValidationError, BadRequestError} = require('@tryghost/errors');
const tpl = require('@tryghost/tpl');
const messages = {
priceMustBeInteger: 'Tier prices must be an integer.',
priceIsNegative: 'Tier prices must not be negative',
maxPriceExceeded: 'Tier prices may not exceed 999999.99'
};
/**
* @typedef {object} ProductModel
*/
/**
* @typedef {object} StripePriceInput
* @param {string} nickname
* @param {string} currency
* @param {number} amount
* @param {'recurring'|'one-time'} type
* @param {string | null} interval
* @param {string?} stripe_product_id
* @param {string?} stripe_price_id
*/
/**
* @typedef {object} BenefitInput
* @param {string} name
*/
function validatePrice(price) {
if (!Number.isInteger(price.amount)) {
throw new ValidationError({
message: tpl(messages.priceMustBeInteger)
});
}
if (price.amount < 0) {
throw new ValidationError({
message: tpl(messages.priceIsNegative)
});
}
if (price.amount > 9999999999) {
throw new ValidationError({
message: tpl(messages.maxPriceExceeded)
});
}
}
class ProductRepository {
/**
* @param {object} deps
* @param {any} deps.Product
* @param {any} deps.Settings
* @param {any} deps.StripeProduct
* @param {any} deps.StripePrice
* @param {import('@tryghost/members-api/lib/services/stripe-api')} deps.stripeAPIService
*/
constructor({
Product,
Settings,
StripeProduct,
StripePrice,
stripeAPIService
}) {
this._Product = Product;
this._Settings = Settings;
this._StripeProduct = StripeProduct;
this._StripePrice = StripePrice;
this._stripeAPIService = stripeAPIService;
}
/**
* Retrieves a Product by either stripe_product_id, stripe_price_id, id or slug
*
* @param {{stripe_product_id: string} | {stripe_price_id: string} | {id: string} | {slug: string}} data
* @param {object} options
*
* @returns {Promise<ProductModel>}
*/
async get(data, options = {}) {
if (!options.transacting) {
return this._Product.transaction((transacting) => {
return this.get(data, {
...options,
transacting
});
});
}
if ('stripe_product_id' in data) {
const stripeProduct = await this._StripeProduct.findOne({
stripe_product_id: data.stripe_product_id
}, options);
if (!stripeProduct) {
return null;
}
return await stripeProduct.related('product').fetch(options);
}
if ('stripe_price_id' in data) {
const stripePrice = await this._StripePrice.findOne({
stripe_price_id: data.stripe_price_id
}, options);
if (!stripePrice) {
return null;
}
const stripeProduct = await stripePrice.related('stripeProduct').fetch(options);
if (!stripeProduct) {
return null;
}
return await stripeProduct.related('product').fetch(options);
}
if ('id' in data) {
return await this._Product.findOne({id: data.id}, options);
}
if ('slug' in data) {
return await this._Product.findOne({slug: data.slug}, options);
}
throw new NotFoundError({message: 'Missing id, slug, stripe_product_id or stripe_price_id from data'});
}
/**
* Creates a product from a name
*
* @param {object} data
* @param {string} data.name
* @param {string} data.description
* @param {'public'|'none'} data.visibility
* @param {string} data.welcome_page_url
* @param {BenefitInput[]} data.benefits
* @param {StripePriceInput[]} data.stripe_prices
* @param {StripePriceInput|null} data.monthly_price
* @param {StripePriceInput|null} data.yearly_price
* @param {string} data.product_id
* @param {string} data.stripe_product_id
* @param {number} data.trial_days
*
* @param {object} options
*
* @returns {Promise<ProductModel>}
**/
async create(data, options = {}) {
if (!this._stripeAPIService.configured && (data.stripe_prices || data.monthly_price || data.yearly_price)) {
throw new UpdateCollisionError({
message: 'The requested functionality requires Stripe to be configured. See https://ghost.org/integrations/stripe/',
code: 'STRIPE_NOT_CONFIGURED'
});
}
if (!options.transacting) {
return this._Product.transaction((transacting) => {
return this.create(data, {
...options,
transacting
});
});
}
if (data.monthly_price) {
validatePrice(data.monthly_price);
}
if (data.yearly_price) {
validatePrice(data.monthly_price);
}
if (data.yearly_price && data.monthly_price && data.yearly_price.currency !== data.monthly_price.currency) {
throw new BadRequestError({
message: 'The monthly and yearly price must use the same currency'
});
}
if (data.stripe_prices) {
data.stripe_prices.forEach(validatePrice);
}
const productData = {
type: 'paid',
active: true,
visibility: data.visibility,
name: data.name,
description: data.description,
benefits: data.benefits,
welcome_page_url: data.welcome_page_url
};
if (data.monthly_price) {
productData.monthly_price = data.monthly_price.amount;
productData.currency = data.monthly_price.currency;
}
if (data.yearly_price) {
productData.yearly_price = data.yearly_price.amount;
productData.currency = data.yearly_price.currency;
}
if (Reflect.has(data, 'trial_days')) {
productData.trial_days = data.trial_days;
}
const product = await this._Product.add(productData, options);
if (this._stripeAPIService.configured) {
const stripeProduct = await this._stripeAPIService.createProduct({
name: productData.name
});
await this._StripeProduct.add({
product_id: product.id,
stripe_product_id: stripeProduct.id
}, options);
if (data.monthly_price || data.yearly_price) {
if (data.monthly_price) {
const price = await this._stripeAPIService.createPrice({
product: stripeProduct.id,
active: true,
nickname: `Monthly`,
currency: data.monthly_price.currency,
amount: data.monthly_price.amount,
type: 'recurring',
interval: 'month'
});
const stripePrice = await this._StripePrice.add({
stripe_price_id: price.id,
stripe_product_id: stripeProduct.id,
active: true,
nickname: price.nickname,
currency: price.currency,
amount: price.unit_amount,
type: 'recurring',
interval: 'month'
}, options);
await this._Product.edit({monthly_price_id: stripePrice.id}, {id: product.id, transacting: options.transacting});
}
if (data.yearly_price) {
const price = await this._stripeAPIService.createPrice({
product: stripeProduct.id,
active: true,
nickname: `Yearly`,
currency: data.yearly_price.currency,
amount: data.yearly_price.amount,
type: 'recurring',
interval: 'year'
});
const stripePrice = await this._StripePrice.add({
stripe_price_id: price.id,
stripe_product_id: stripeProduct.id,
active: true,
nickname: price.nickname,
currency: price.currency,
amount: price.unit_amount,
type: 'recurring',
interval: 'year'
}, options);
await this._Product.edit({yearly_price_id: stripePrice.id}, {id: product.id, transacting: options.transacting});
}
} else if (data.stripe_prices) {
for (const newPrice of data.stripe_prices) {
const price = await this._stripeAPIService.createPrice({
product: stripeProduct.id,
active: true,
nickname: newPrice.nickname,
currency: newPrice.currency,
amount: newPrice.amount,
type: newPrice.type,
interval: newPrice.interval
});
await this._StripePrice.add({
stripe_price_id: price.id,
stripe_product_id: stripeProduct.id,
active: true,
nickname: newPrice.nickname,
currency: newPrice.currency,
amount: newPrice.amount,
type: newPrice.type,
interval: newPrice.interval
}, options);
}
}
await product.related('stripePrices').fetch(options);
await product.related('monthlyPrice').fetch(options);
await product.related('yearlyPrice').fetch(options);
}
return product;
}
/**
* Updates a product by id
*
* @param {object} data
* @param {string} data.id
* @param {string} data.name
* @param {string} data.description
* @param {number} data.trial_days
* @param {'public'|'none'} data.visibility
* @param {string} data.welcome_page_url
* @param {BenefitInput[]} data.benefits
*
* @param {StripePriceInput[]} [data.stripe_prices]
* @param {StripePriceInput|null} data.monthly_price
* @param {StripePriceInput|null} data.yearly_price
*
* @param {object} options
*
* @returns {Promise<ProductModel>}
**/
async update(data, options = {}) {
if (!this._stripeAPIService.configured && (data.stripe_prices || data.monthly_price || data.yearly_price)) {
throw new UpdateCollisionError({
message: 'The requested functionality requires Stripe to be configured. See https://ghost.org/integrations/stripe/',
code: 'STRIPE_NOT_CONFIGURED'
});
}
if (!options.transacting) {
return this._Product.transaction((transacting) => {
return this.update(data, {
...options,
transacting
});
});
}
if (data.monthly_price) {
validatePrice(data.monthly_price);
}
if (data.yearly_price) {
validatePrice(data.monthly_price);
}
if (data.stripe_prices) {
data.stripe_prices.forEach(validatePrice);
}
if (data.yearly_price && data.monthly_price && data.yearly_price.currency !== data.monthly_price.currency) {
throw new BadRequestError({
message: 'The monthly and yearly price must use the same currency'
});
}
const productId = data.id || options.id;
const existingProduct = await this._Product.findOne({id: productId}, options);
let productData = {
name: data.name,
visibility: data.visibility,
description: data.description,
benefits: data.benefits,
welcome_page_url: data.welcome_page_url
};
if (data.monthly_price) {
productData.monthly_price = data.monthly_price.amount;
productData.currency = data.monthly_price.currency;
}
if (data.yearly_price) {
productData.yearly_price = data.yearly_price.amount;
productData.currency = data.yearly_price.currency;
}
if (Reflect.has(data, 'active')) {
productData.active = data.active;
}
if (Reflect.has(data, 'trial_days')) {
productData.trial_days = data.trial_days;
}
if (existingProduct.get('type') === 'free') {
delete productData.name;
delete productData.active;
delete productData.trial_days;
}
if (existingProduct.get('active') === true && productData.active === false) {
const portalProductsSetting = await this._Settings.findOne({
key: 'portal_products'
}, options);
let portalProducts;
try {
portalProducts = JSON.parse(portalProductsSetting.get('value'));
} catch (err) {
portalProducts = [];
}
const updatedProducts = portalProducts.filter(product => product !== productId);
await this._Settings.edit({
key: 'portal_products',
value: JSON.stringify(updatedProducts)
}, {
...options,
id: portalProductsSetting.get('id')
});
}
let product = await this._Product.edit(productData, {
...options,
id: productId
});
if (this._stripeAPIService.configured && product.get('type') !== 'free') {
await product.related('stripeProducts').fetch(options);
if (!product.related('stripeProducts').first()) {
const stripeProduct = await this._stripeAPIService.createProduct({
name: product.get('name')
});
await this._StripeProduct.add({
product_id: product.id,
stripe_product_id: stripeProduct.id
}, options);
await product.related('stripeProducts').fetch(options);
} else {
if (product.attributes.name !== product._previousAttributes.name) {
const stripeProduct = product.related('stripeProducts').first();
await this._stripeAPIService.updateProduct(stripeProduct.get('stripe_product_id'), {
name: product.get('name')
});
}
}
const defaultStripeProduct = product.related('stripeProducts').first();
if (data.monthly_price || data.yearly_price) {
if (data.monthly_price) {
const existingPrice = await this._StripePrice.findOne({
stripe_product_id: defaultStripeProduct.get('stripe_product_id'),
amount: data.monthly_price.amount,
currency: data.monthly_price.currency,
type: 'recurring',
interval: 'month',
active: true
}, options);
let priceModel;
if (existingPrice) {
priceModel = existingPrice;
await this._stripeAPIService.updatePrice(priceModel.get('stripe_price_id'), {
active: true
});
await this._StripePrice.edit({
active: true
}, {...options, id: priceModel.id});
} else {
const price = await this._stripeAPIService.createPrice({
product: defaultStripeProduct.get('stripe_product_id'),
active: true,
nickname: `Monthly`,
currency: data.monthly_price.currency,
amount: data.monthly_price.amount,
type: 'recurring',
interval: 'month'
});
const stripePrice = await this._StripePrice.add({
stripe_price_id: price.id,
stripe_product_id: defaultStripeProduct.get('stripe_product_id'),
active: true,
nickname: price.nickname,
currency: price.currency,
amount: price.unit_amount,
type: 'recurring',
interval: 'month'
}, options);
priceModel = stripePrice;
}
product = await this._Product.edit({monthly_price_id: priceModel.id}, {...options, id: product.id});
}
if (data.yearly_price) {
const existingPrice = await this._StripePrice.findOne({
stripe_product_id: defaultStripeProduct.get('stripe_product_id'),
amount: data.yearly_price.amount,
currency: data.yearly_price.currency,
type: 'recurring',
interval: 'year',
active: true
}, options);
let priceModel;
if (existingPrice) {
priceModel = existingPrice;
await this._stripeAPIService.updatePrice(priceModel.get('stripe_price_id'), {
active: true
});
await this._StripePrice.edit({
active: true
}, {...options, id: priceModel.id});
} else {
const price = await this._stripeAPIService.createPrice({
product: defaultStripeProduct.get('stripe_product_id'),
active: true,
nickname: `Yearly`,
currency: data.yearly_price.currency,
amount: data.yearly_price.amount,
type: 'recurring',
interval: 'year'
});
const stripePrice = await this._StripePrice.add({
stripe_price_id: price.id,
stripe_product_id: defaultStripeProduct.get('stripe_product_id'),
active: true,
nickname: price.nickname,
currency: price.currency,
amount: price.unit_amount,
type: 'recurring',
interval: 'year'
}, options);
priceModel = stripePrice;
}
product = await this._Product.edit({yearly_price_id: priceModel.id}, {...options, id: product.id});
}
} else if (data.stripe_prices) {
const newPrices = data.stripe_prices.filter(price => !price.stripe_price_id);
const existingPrices = data.stripe_prices.filter((price) => {
return !!price.stripe_price_id && !!price.stripe_product_id;
});
for (const existingPrice of existingPrices) {
const existingProductId = existingPrice.stripe_product_id;
let stripeProduct = await this._StripeProduct.findOne({stripe_product_id: existingProductId}, options);
if (!stripeProduct) {
stripeProduct = await this._StripeProduct.add({
product_id: product.id,
stripe_product_id: existingProductId
}, options);
}
const stripePrice = await this._StripePrice.findOne({stripe_price_id: existingPrice.stripe_price_id}, options);
if (!stripePrice) {
await this._StripePrice.add({
stripe_price_id: existingPrice.stripe_price_id,
stripe_product_id: stripeProduct.get('stripe_product_id'),
active: existingPrice.active,
nickname: existingPrice.nickname,
description: existingPrice.description,
currency: existingPrice.currency,
amount: existingPrice.amount,
type: existingPrice.type,
interval: existingPrice.interval
}, options);
} else {
const updated = await this._StripePrice.edit({
nickname: existingPrice.nickname,
description: existingPrice.description,
active: existingPrice.active
}, {
...options,
id: stripePrice.id
});
await this._stripeAPIService.updatePrice(updated.get('stripe_price_id'), {
nickname: updated.get('nickname'),
active: updated.get('active')
});
}
}
for (const newPrice of newPrices) {
const newProductId = newPrice.stripe_product_id;
const stripeProduct = newProductId ?
await this._StripeProduct.findOne({stripe_product_id: newProductId}, options) : defaultStripeProduct;
const price = await this._stripeAPIService.createPrice({
product: stripeProduct.get('stripe_product_id'),
active: true,
nickname: newPrice.nickname,
currency: newPrice.currency,
amount: newPrice.amount,
type: newPrice.type,
interval: newPrice.interval
});
await this._StripePrice.add({
stripe_price_id: price.id,
stripe_product_id: stripeProduct.get('stripe_product_id'),
active: price.active,
nickname: price.nickname,
description: newPrice.description,
currency: price.currency,
amount: price.unit_amount,
type: price.type,
interval: price.recurring && price.recurring.interval || null
}, options);
}
}
await product.related('stripePrices').fetch(options);
await product.related('monthlyPrice').fetch(options);
await product.related('yearlyPrice').fetch(options);
await product.related('benefits').fetch(options);
}
return product;
}
/**
* Returns a paginated list of Products
*
* @params {object} options
*
* @returns {Promise<{data: ProductModel[], meta: object}>}
**/
async list(options = {}) {
if (!options.transacting) {
return this._Product.transaction((transacting) => {
return this.list({
...options,
transacting
});
});
}
return this._Product.findPage(options);
}
async destroy() {
throw new MethodNotAllowedError({message: 'Cannot destroy products, yet...'});
}
}
module.exports = ProductRepository;