TryGhost/Ghost

View on GitHub
ghost/members-api/lib/repositories/ProductRepository.js

Summary

Maintainability
F
1 wk
Test Coverage
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('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;