mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-21 01:41:46 +03:00
ba41f308c7
closes https://github.com/TryGhost/Team/issues/2196 We were incorrectly assuming that all requests would have the `customerEmail` passed in the body. Instead we were incorrectly passing `undefined` or `''` as the `customerEmail` property to stripe, which resulted in a validation error. We've updated the code to pass `null` in the case of a falsy value, which the Stripe API handles without error.
304 lines
9.6 KiB
JavaScript
304 lines
9.6 KiB
JavaScript
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 session = await this.stripeAPIService.createCheckoutSession(price.id, customer, {
|
|
metadata,
|
|
successUrl: options.successUrl,
|
|
cancelUrl: options.cancelUrl,
|
|
customerEmail: customer ? email : null,
|
|
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.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) {
|
|
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<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;
|
|
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<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;
|