82ed10473b
refs https://github.com/TryGhost/Team/issues/1869 - There are multiple places in the codebase fetching "default product". The code is slightly divergent in each one of them and has been a source of bugs (like the one referenced). Having the logic captured in one place will allow reducing the code duplication, making code less bug prone, and making testing the modules dependent on the "setDefaultProduct" method easier
670 lines
24 KiB
JavaScript
670 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'});
|
|
}
|
|
|
|
/**
|
|
* Fetches the default product
|
|
* @param {Object} options
|
|
* @returns {Promise<ProductModel>}
|
|
*/
|
|
async getDefaultProduct(options = {}) {
|
|
const defaultProductPage = await this.list({
|
|
filter: 'type:paid+active:true',
|
|
limit: 1,
|
|
...options
|
|
});
|
|
|
|
return defaultProductPage.data[0];
|
|
}
|
|
|
|
/**
|
|
* 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;
|