Ghost/ghost/members-api/lib/repositories/product.js
Fabien 'egg' O'Carroll 453f828aa7
Restricted existing prices to those that are active (#15195)
Without this check, an inactive price in our database will just be
reactivated each time it is required. This can cause issues when
prices have been deleted.

By adding this constraint to the query, we will create a new price in
Stripe and our database when attempting to use an inactive price, this
is particularly useful when trying to fix problems caused by Stripe
prices being deleted.
2022-08-08 16:18:07 +01:00

623 lines
23 KiB
JavaScript

const {UpdateCollisionError, NotFoundError, MethodNotAllowedError, ValidationError} = 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.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 (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);
}
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 (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 productId = existingPrice.stripe_product_id;
let stripeProduct = await this._StripeProduct.findOne({stripe_product_id: productId}, options);
if (!stripeProduct) {
stripeProduct = await this._StripeProduct.add({
product_id: product.id,
stripe_product_id: productId
}, 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 productId = newPrice.stripe_product_id;
const stripeProduct = productId ?
await this._StripeProduct.findOne({stripe_product_id: productId}, 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;