Ghost/ghost/payments/lib/PaymentsService.js
Fabien "egg" O'Carroll 104f84f252 Added eslint rule for file naming convention
As discussed with the product team we want to enforce kebab-case file names for
all files, with the exception of files which export a single class, in which
case they should be PascalCase and reflect the class which they export.

This will help find classes faster, and should push better naming for them too.

Some files and packages have been excluded from this linting, specifically when
a library or framework depends on the naming of a file for the functionality
e.g. Ember, knex-migrator, adapter-manager
2023-05-09 12:34:34 -04:00

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;