33458daae8
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
333 lines
11 KiB
JavaScript
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;
|