Ghost/ghost/payments/lib/payments.js
Rishabh Garg 33458daae8
🐛 Fixed free trial applied alongside an offer in checkout (#15975)
refs https://github.com/TryGhost/Team/issues/2375

If a discount offer is associated with a tier that has a free trial enabled on full price / standard portal in membership settings, then the stripe checkout applied both the discount and free trial to the member, which is incorrect as we shouldn't be combining both.

- removes trial days from stripe checkout if a coupon is being applied, so only one of them is applied at a time
2022-12-09 18:47:05 +05:30

333 lines
11 KiB
JavaScript

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
*/
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;
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 {object} params.options
* @param {string} params.options.successUrl
* @param {string} params.options.cancelUrl
* @param {string} [params.options.email]
*
* @returns {Promise<URL>}
*/
async getPaymentLink({tier, cadence, offer, member, metadata, options}) {
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 email = options.email || null;
const data = {
metadata,
successUrl: options.successUrl,
cancelUrl: options.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;
}
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
});
}
}
/**
* @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
}).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;