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} */ 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} */ 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} **/ 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} **/ 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('stripeProducts').fetch(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 = {}) { return this._Product.findPage(options); } async destroy() { throw new MethodNotAllowedError({message: 'Cannot destroy products, yet...'}); } } module.exports = ProductRepository;