Ghost/ghost/payments/lib/payments.js
Fabien "egg" O'Carroll 69aa52bd8e 🐛 Handled deleted Stripe objects in the Stripe Checkout flow
closes https://github.com/TryGhost/Team/issues/2222

Whilst we were checking for Stripe objects being active, we were not
checking for them existing in Stripe. This adds handling to all read
request to Stripe in the payment link flow, so that we can gracefully
handle deleted objects.

We've also included an automated test which fails without this fix.

We've also improved the query to find Stripe Prices which will result
in less request to the Stripe API to check if it is valid.
2022-11-08 16:03:07 +07:00

321 lines
10 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 (!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
}).query().select('stripe_price_id');
for (const row of rows) {
try {
const price = await this.stripeAPIService.getPrice(row.stripe_price_id);
if (price.active && price.currency.toUpperCase() === currency && price.unit_amount === amount && price.recurring?.interval === cadence) {
return {
id: price.id
};
}
} catch (err) {
logging.warn(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;