diff --git a/ghost/members-api/lib/MembersAPI.js b/ghost/members-api/lib/MembersAPI.js index b4cf61d599..6ed7fabe01 100644 --- a/ghost/members-api/lib/MembersAPI.js +++ b/ghost/members-api/lib/MembersAPI.js @@ -158,6 +158,9 @@ module.exports = function MembersAPI({ }); const paymentsService = new PaymentsService({ + StripeProduct, + StripePrice, + StripeCustomer, Offer, offersAPI, stripeAPIService diff --git a/ghost/payments/lib/payments.js b/ghost/payments/lib/payments.js index 2d6e83e792..b050e5917f 100644 --- a/ghost/payments/lib/payments.js +++ b/ghost/payments/lib/payments.js @@ -1,5 +1,7 @@ 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 { /** @@ -12,6 +14,12 @@ class PaymentsService { /** @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; @@ -19,6 +27,224 @@ class PaymentsService { 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.} [params.metadata] + * @param {object} params.options + * @param {string} params.options.successUrl + * @param {string} params.options.cancelUrl + * @param {string} [params.options.email] + * + * @returns {Promise} + */ + async getPaymentLink({tier, cadence, offer, member, metadata, options}) { + let coupon = null; + let trialDays = null; + if (offer) { + if (offer.tier.id !== 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 session = await this.stripeAPIService.createCheckoutSession(price.id, customer, { + metadata, + successUrl: options.successUrl, + cancelUrl: options.cancelUrl, + customerEmail: options.email, + trialDays: trialDays ?? tier.trialDays, + coupon: coupon?.id + }); + + 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) { + const customer = await this.stripeAPIService.getCustomer(row.customer_id); + if (!customer.deleted) { + return { + id: customer.id + }; + } + } + + const customer = await this.createCustomerForMember(member); + + return customer; + } + + async createCustomerForMember(member) { + const customer = await this.stripeAPIService.createCustomer({ + email: member.email, + name: member.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) { + const product = await this.stripeAPIService.getProduct(row.stripe_product_id); + if (product.active) { + return {id: product.id}; + } + } + + const product = await this.createProductForTier(tier); + + return { + id: product.id + }; + } + + /** + * @param {import('@tryghost/tiers').Tier} tier + * @returns {Promise} + */ + 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} + */ + 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 + }); + } + } + + /** + * @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; + const amount = tier.getPrice(cadence); + const rows = await this.StripePriceModel.where({ + stripe_product_id: product.id, + currency, + amount + }).query().select('stripe_price_id'); + + for (const row of rows) { + const price = await this.stripeAPIService.getPrice(row.stripe_price_id); + if (price.active && price.currency.toUpperCase() === currency && price.unit_amount === amount) { + return { + id: price.id + }; + } + } + + const price = await this.createPriceForTierCadence(tier, cadence); + + return { + id: price.id + }; + } + + /** + * @param {import('@tryghost/tiers').Tier} tier + * @param {'month'|'year'} cadence + * @returns {Promise} + */ + 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; } /** diff --git a/ghost/payments/package.json b/ghost/payments/package.json index 125b4cdc97..370959fc7d 100644 --- a/ghost/payments/package.json +++ b/ghost/payments/package.json @@ -23,6 +23,8 @@ }, "dependencies": { "@tryghost/domain-events": "0.0.0", - "@tryghost/members-offers": "0.0.0" + "@tryghost/errors": "1.2.18", + "@tryghost/members-offers": "0.0.0", + "@tryghost/tiers": "0.0.0" } }