TryGhost/Ghost

View on GitHub
ghost/payments/lib/PaymentsService.js

Summary

Maintainability
D
2 days
Test Coverage
const logging = require('@tryghost/logging');
const DomainEvents = require('@tryghost/domain-events');
const {TierCreatedEvent, TierPriceChangeEvent, TierNameChangeEvent} = require('@tryghost/tiers');
const OfferCreatedEvent = require('@tryghost/members-offers').events.OfferCreatedEvent;
const {BadRequestError} = require('@tryghost/errors');

class PaymentsService {
    /**
     * @param {object} deps
     * @param {import('bookshelf').Model} deps.Offer
     * @param {import('@tryghost/members-offers/lib/application/OffersAPI')} deps.offersAPI
     * @param {import('@tryghost/members-stripe-service/lib/StripeAPI')} deps.stripeAPIService
     * @param {{get(key: string): any}} deps.settingsCache
     */
    constructor(deps) {
        /** @private */
        this.OfferModel = deps.Offer;
        /** @private */
        this.StripeProductModel = deps.StripeProduct;
        /** @private */
        this.StripePriceModel = deps.StripePrice;
        /** @private */
        this.StripeCustomerModel = deps.StripeCustomer;
        /** @private */
        this.offersAPI = deps.offersAPI;
        /** @private */
        this.stripeAPIService = deps.stripeAPIService;
        /** @private */
        this.settingsCache = deps.settingsCache;

        DomainEvents.subscribe(OfferCreatedEvent, async (event) => {
            await this.getCouponForOffer(event.data.offer.id);
        });

        DomainEvents.subscribe(TierCreatedEvent, async (event) => {
            if (event.data.tier.type === 'paid') {
                await this.getPriceForTierCadence(event.data.tier, 'month');
                await this.getPriceForTierCadence(event.data.tier, 'year');
            }
        });

        DomainEvents.subscribe(TierPriceChangeEvent, async (event) => {
            if (event.data.tier.type === 'paid') {
                await this.getPriceForTierCadence(event.data.tier, 'month');
                await this.getPriceForTierCadence(event.data.tier, 'year');
            }
        });

        DomainEvents.subscribe(TierNameChangeEvent, async (event) => {
            if (event.data.tier.type === 'paid') {
                await this.updateNameForTierProducts(event.data.tier);
            }
        });
    }

    /**
     * @param {object} params
     * @param {Tier} params.tier
     * @param {Tier.Cadence} params.cadence
     * @param {Offer} [params.offer]
     * @param {Member} [params.member]
     * @param {Object.<string, any>} [params.metadata]
     * @param {string} params.successUrl
     * @param {string} params.cancelUrl
     * @param {string} [params.email]
     *
     * @returns {Promise<URL>}
     */
    async getPaymentLink({tier, cadence, offer, member, metadata, successUrl, cancelUrl, email}) {
        let coupon = null;
        let trialDays = null;
        if (offer) {
            if (!tier.id.equals(offer.tier.id)) {
                throw new BadRequestError({
                    message: 'This Offer is not valid for the Tier'
                });
            }
            if (offer.type === 'trial') {
                trialDays = offer.amount;
            } else {
                coupon = await this.getCouponForOffer(offer.id);
            }
        }

        let customer = null;
        if (member) {
            customer = await this.getCustomerForMember(member);
        }

        const price = await this.getPriceForTierCadence(tier, cadence);

        const data = {
            metadata,
            successUrl: successUrl,
            cancelUrl: cancelUrl,
            trialDays: trialDays ?? tier.trialDays,
            coupon: coupon?.id
        };

        // If we already have a coupon, we don't want to give trial days over it
        if (data.coupon) {
            delete data.trialDays;
        }

        if (!customer && email) {
            data.customerEmail = email;
        }

        const session = await this.stripeAPIService.createCheckoutSession(price.id, customer, data);

        return session.url;
    }

    /**
     * @param {object} params
     * @param {Member} [params.member]
     * @param {Object.<string, any>} [params.metadata]
     * @param {string} params.successUrl
     * @param {string} params.cancelUrl
     * @param {boolean} [params.isAuthenticated]
     * @param {string} [params.email]
     *
     * @returns {Promise<URL>}
     */
    async getDonationPaymentLink({member, metadata, successUrl, cancelUrl, email, isAuthenticated}) {
        let customer = null;
        if (member && isAuthenticated) {
            customer = await this.getCustomerForMember(member);
        }

        const data = {
            priceId: (await this.getPriceForDonations()).id,
            metadata,
            successUrl: successUrl,
            cancelUrl: cancelUrl,
            customer,
            customerEmail: !customer && email ? email : null
        };

        const session = await this.stripeAPIService.createDonationCheckoutSession(data);
        return session.url;
    }

    async getCustomerForMember(member) {
        const rows = await this.StripeCustomerModel.where({
            member_id: member.id
        }).query().select('customer_id');

        for (const row of rows) {
            try {
                const customer = await this.stripeAPIService.getCustomer(row.customer_id);
                if (!customer.deleted) {
                    return customer;
                }
            } catch (err) {
                logging.warn(err);
            }
        }

        const customer = await this.createCustomerForMember(member);

        return customer;
    }

    async createCustomerForMember(member) {
        const customer = await this.stripeAPIService.createCustomer({
            email: member.get('email'),
            name: member.get('name')
        });

        await this.StripeCustomerModel.add({
            member_id: member.id,
            customer_id: customer.id,
            email: customer.email,
            name: customer.name
        });

        return customer;
    }

    /**
     * @param {import('@tryghost/tiers').Tier} tier
     * @returns {Promise<{id: string}>}
     */
    async getProductForTier(tier) {
        const rows = await this.StripeProductModel
            .where({product_id: tier.id.toHexString()})
            .query()
            .select('stripe_product_id');

        for (const row of rows) {
            try {
                const product = await this.stripeAPIService.getProduct(row.stripe_product_id);
                if (product.active) {
                    return {id: product.id};
                }
            } catch (err) {
                logging.warn(err);
            }
        }

        const product = await this.createProductForTier(tier);

        return {
            id: product.id
        };
    }

    /**
     * @param {import('@tryghost/tiers').Tier} tier
     * @returns {Promise<import('stripe').default.Product>}
     */
    async createProductForTier(tier) {
        const product = await this.stripeAPIService.createProduct({name: tier.name});
        await this.StripeProductModel.add({
            product_id: tier.id.toHexString(),
            stripe_product_id: product.id
        });
        return product;
    }

    /**
     * @param {import('@tryghost/tiers').Tier} tier
     * @returns {Promise<void>}
     */
    async updateNameForTierProducts(tier) {
        const rows = await this.StripeProductModel
            .where({product_id: tier.id.toHexString()})
            .query()
            .select('stripe_product_id');

        for (const row of rows) {
            await this.stripeAPIService.updateProduct(row.stripe_product_id, {
                name: tier.name
            });
        }
    }

    /**
     * @returns {Promise<{id: string}>}
     */
    async getProductForDonations({name}) {
        const existingDonationPrices = await this.StripePriceModel
            .where({
                type: 'donation'
            })
            .query()
            .select('stripe_product_id');

        for (const row of existingDonationPrices) {
            const product = await this.StripeProductModel
                .where({
                    stripe_product_id: row.stripe_product_id
                })
                .query()
                .select('stripe_product_id')
                .first();

            if (product) {
                // Check active in Stripe
                try {
                    const stripeProduct = await this.stripeAPIService.getProduct(row.stripe_product_id);
                    if (stripeProduct.active) {
                        return {id: stripeProduct.id};
                    }
                } catch (err) {
                    logging.warn(err);
                }
            }
        }

        const product = await this.createProductForDonations({name});

        return {
            id: product.id
        };
    }

    /**
     * @returns {Promise<{id: string}>}
     */
    async getPriceForDonations() {
        const nickname = 'Support ' + this.settingsCache.get('title');
        const currency = this.settingsCache.get('donations_currency');
        const suggestedAmount = this.settingsCache.get('donations_suggested_amount');

        // Stripe requires a minimum charge amount
        // @see https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts
        const amount = suggestedAmount && suggestedAmount >= 100 ? suggestedAmount : 0;

        const price = await this.StripePriceModel
            .where({
                type: 'donation',
                active: true,
                amount,
                currency
            })
            .query()
            .select('stripe_price_id', 'stripe_product_id', 'id', 'nickname')
            .first();

        if (price) {
            if (price.nickname !== nickname) {
                // Rename it in Stripe (in case the publication name changed)
                try {
                    await this.stripeAPIService.updatePrice(price.stripe_price_id, {
                        nickname
                    });

                    // Update product too
                    await this.stripeAPIService.updateProduct(price.stripe_product_id, {
                        name: nickname
                    });

                    await this.StripePriceModel.edit({
                        nickname
                    }, {id: price.id});
                } catch (err) {
                    logging.warn(err);
                }
            }
            return {
                id: price.stripe_price_id
            };
        }

        const newPrice = await this.createPriceForDonations({
            nickname,
            currency,
            amount
        });
        return {
            id: newPrice.id
        };
    }

    /**
     * @returns {Promise<import('stripe').default.Price>}
     */
    async createPriceForDonations({currency, amount, nickname}) {
        const product = await this.getProductForDonations({name: nickname});

        const preset = amount ? amount : undefined;

        // Create the price in Stripe
        const price = await this.stripeAPIService.createPrice({
            currency,
            product: product.id,
            custom_unit_amount: {
                enabled: true,
                preset
            },
            nickname,
            type: 'one-time',
            active: true
        });

        // Save it to the database
        await this.StripePriceModel.add({
            stripe_price_id: price.id,
            stripe_product_id: product.id,
            active: price.active,
            nickname: price.nickname,
            currency: price.currency,
            amount,
            type: 'donation',
            interval: null
        });
        return price;
    }

    /**
     * @returns {Promise<import('stripe').default.Product>}
     */
    async createProductForDonations({name}) {
        const product = await this.stripeAPIService.createProduct({
            name
        });

        await this.StripeProductModel.add({
            product_id: null,
            stripe_product_id: product.id
        });
        return product;
    }

    /**
     * @param {import('@tryghost/tiers').Tier} tier
     * @param {'month'|'year'} cadence
     * @returns {Promise<{id: string}>}
     */
    async getPriceForTierCadence(tier, cadence) {
        const product = await this.getProductForTier(tier);
        const currency = tier.currency.toLowerCase();
        const amount = tier.getPrice(cadence);
        const rows = await this.StripePriceModel.where({
            stripe_product_id: product.id,
            currency,
            interval: cadence,
            amount,
            active: true,
            type: 'recurring'
        }).query().select('id', 'stripe_price_id');

        for (const row of rows) {
            try {
                const price = await this.stripeAPIService.getPrice(row.stripe_price_id);
                if (price.active && price.currency.toLowerCase() === currency && price.unit_amount === amount && price.recurring?.interval === cadence) {
                    return {
                        id: price.id
                    };
                } else {
                    // Update the database model to prevent future Stripe fetches when it is not needed
                    await this.StripePriceModel.edit({
                        active: !!price.active
                    }, {id: row.id});
                }
            } catch (err) {
                logging.error(`Failed to lookup Stripe Price ${row.stripe_price_id}`);
                logging.error(err);
            }
        }

        const price = await this.createPriceForTierCadence(tier, cadence);

        return {
            id: price.id
        };
    }

    /**
     * @param {import('@tryghost/tiers').Tier} tier
     * @param {'month'|'year'} cadence
     * @returns {Promise<import('stripe').default.Price>}
     */
    async createPriceForTierCadence(tier, cadence) {
        const product = await this.getProductForTier(tier);
        const price = await this.stripeAPIService.createPrice({
            product: product.id,
            interval: cadence,
            currency: tier.currency,
            amount: tier.getPrice(cadence),
            nickname: cadence === 'month' ? 'Monthly' : 'Yearly',
            type: 'recurring',
            active: true
        });
        await this.StripePriceModel.add({
            stripe_price_id: price.id,
            stripe_product_id: product.id,
            active: price.active,
            nickname: price.nickname,
            currency: price.currency,
            amount: price.unit_amount,
            type: 'recurring',
            interval: cadence
        });
        return price;
    }

    /**
     * @param {string} offerId
     *
     * @returns {Promise<{id: string}>}
     */
    async getCouponForOffer(offerId) {
        const row = await this.OfferModel.where({id: offerId}).query().select('stripe_coupon_id', 'discount_type').first();
        if (!row || row.discount_type === 'trial') {
            return null;
        }
        if (!row.stripe_coupon_id) {
            const offer = await this.offersAPI.getOffer({id: offerId});
            await this.createCouponForOffer(offer);
            return this.getCouponForOffer(offerId);
        }
        return {
            id: row.stripe_coupon_id
        };
    }

    /**
     * @param {import('@tryghost/members-offers/lib/application/OfferMapper').OfferDTO} offer
     */
    async createCouponForOffer(offer) {
        /** @type {import('stripe').Stripe.CouponCreateParams} */
        const couponData = {
            name: offer.name,
            duration: offer.duration
        };

        if (offer.duration === 'repeating') {
            couponData.duration_in_months = offer.duration_in_months;
        }

        if (offer.type === 'percent') {
            couponData.percent_off = offer.amount;
        } else {
            couponData.amount_off = offer.amount;
            couponData.currency = offer.currency;
        }

        const coupon = await this.stripeAPIService.createCoupon(couponData);

        await this.OfferModel.edit({
            stripe_coupon_id: coupon.id
        }, {
            id: offer.id
        });
    }
}

module.exports = PaymentsService;