Updated link subscription to handle missing stripe data (#262)

refs https://github.com/TryGhost/Team/issues/619

On linking a stripe subscription to a member, this change -

- Adds missing stripe price or stripe product from subscription to DB
  - Missing Stripe price is attached to the first Ghost Product if no matching Product exists
- Updates usage from plan to price in the `linkSubscription` method
- Updates products associated with a member based on active subscriptions
This commit is contained in:
Rishabh Garg 2021-04-20 17:21:16 +05:30 committed by GitHub
parent dc0e5b0ec8
commit bee619c123
4 changed files with 129 additions and 13 deletions

View File

@ -90,6 +90,7 @@ module.exports = function MembersApi({
stripeAPIService,
stripePlansService,
logger,
productRepository,
Member,
MemberSubscribeEvent,
MemberPaidSubscriptionEvent,

View File

@ -9,6 +9,7 @@ module.exports = class MemberRepository {
* @param {any} deps.MemberStatusEvent
* @param {any} deps.StripeCustomer
* @param {any} deps.StripeCustomerSubscription
* @param {any} deps.productRepository
* @param {import('../../services/stripe-api')} deps.stripeAPIService
* @param {import('../../services/stripe-plans')} deps.stripePlansService
* @param {any} deps.logger
@ -23,6 +24,7 @@ module.exports = class MemberRepository {
StripeCustomerSubscription,
stripeAPIService,
stripePlansService,
productRepository,
logger
}) {
this._Member = Member;
@ -34,6 +36,7 @@ module.exports = class MemberRepository {
this._StripeCustomerSubscription = StripeCustomerSubscription;
this._stripeAPIService = stripeAPIService;
this._stripePlansService = stripePlansService;
this._productRepository = productRepository;
this._logging = logger;
}
@ -268,6 +271,42 @@ module.exports = class MemberRepository {
const model = await this._StripeCustomerSubscription.findOne({
subscription_id: subscription.id
}, options);
const subscriptionPriceData = _.get(subscription, 'items.data[0].price');
let ghostProduct;
try {
ghostProduct = await this._productRepository.get({stripe_product_id: subscriptionPriceData.product}, options);
// Use first Ghost product as default product in case of missing link
if (!ghostProduct) {
let {data: pageData} = await this._productRepository.list({limit: 1});
ghostProduct = (pageData && pageData[0]) || null;
}
// Link Stripe Product & Price to Ghost Product
if (ghostProduct) {
await this._productRepository.update({
id: ghostProduct.get('id'),
name: ghostProduct.get('name'),
stripe_prices: [
{
stripe_price_id: subscriptionPriceData.id,
stripe_product_id: subscriptionPriceData.product,
active: subscriptionPriceData.active,
nickname: subscriptionPriceData.nickname,
currency: subscriptionPriceData.currency,
amount: subscriptionPriceData.unit_amount,
type: subscriptionPriceData.type,
interval: (subscriptionPriceData.recurring && subscriptionPriceData.recurring.interval) || null
}
]
}, options);
} else {
// Log error if no Ghost products found
this._logging.error(`There was an error linking subscription - ${subscription.id}, no Products exist.`);
}
} catch (e) {
this._logging.error(`Failed to handle prices and product for - ${subscription.id}.`);
this._logging.error(e);
}
const subscriptionData = {
customer_id: subscription.customer,
@ -278,18 +317,17 @@ module.exports = class MemberRepository {
current_period_end: new Date(subscription.current_period_end * 1000),
start_date: new Date(subscription.start_date * 1000),
default_payment_card_last4: paymentMethod && paymentMethod.card && paymentMethod.card.last4 || null,
plan_id: subscription.plan.id,
stripe_price_id: subscriptionPriceData.id,
plan_id: subscriptionPriceData.id,
// NOTE: Defaulting to interval as migration to nullable field
// turned out to be much bigger problem.
// Ideally, would need nickname field to be nullable on the DB level
// condition can be simplified once this is done
plan_nickname: subscription.plan.nickname || subscription.plan.interval,
plan_interval: subscription.plan.interval,
plan_amount: subscription.plan.amount,
plan_currency: subscription.plan.currency
plan_nickname: subscriptionPriceData.nickname || _.get(subscriptionPriceData, 'recurring.interval'),
plan_interval: _.get(subscriptionPriceData, 'recurring.interval', ''),
plan_amount: subscriptionPriceData.unit_amount,
plan_currency: subscriptionPriceData.currency
};
function getMRRDelta({interval, amount, status}) {
if (status === 'trialing') {
return 0;
@ -327,7 +365,7 @@ module.exports = class MemberRepository {
source: 'stripe',
from_plan: model.get('plan_id'),
to_plan: updated.get('plan_id'),
currency: subscription.plan.currency,
currency: subscriptionPriceData.currency,
mrr_delta: mrrDelta
}, options);
}
@ -337,24 +375,46 @@ module.exports = class MemberRepository {
member_id: member.id,
source: 'stripe',
from_plan: null,
to_plan: subscription.plan.id,
currency: subscription.plan.currency,
mrr_delta: getMRRDelta({interval: subscription.plan.interval, amount: subscription.plan.amount, status: subscription.status})
to_plan: subscriptionPriceData.id,
currency: subscriptionPriceData.currency,
mrr_delta: getMRRDelta({interval: _.get(subscriptionPriceData, 'recurring.interval'), amount: subscriptionPriceData.unit_amount, status: subscriptionPriceData.status})
}, options);
}
let status = 'free';
let memberProducts = [];
if (this.isActiveSubscriptionStatus(subscription.status)) {
if (this.isComplimentarySubscription(subscription)) {
status = 'comped';
} else {
status = 'paid';
}
try {
if (ghostProduct) {
memberProducts.push(ghostProduct.toJSON());
}
const existingProducts = await member.related('products').fetch(options);
for (const productModel of existingProducts.models) {
memberProducts.push(productModel.toJSON());
}
} catch (e) {
this._logging.error(`Failed to attach products to member - ${data.id}`);
}
} else {
const subscriptions = await member.related('stripeSubscriptions').fetch(options);
for (const subscription of subscriptions.models) {
if (this.isActiveSubscriptionStatus(subscription.get('status'))) {
if (status === 'comped' || this.isComplimentarySubscription(subscription)) {
try {
const subscriptionProduct = await this._productRepository.get({stripe_price_id: subscription.get('stripe_price_id')});
if (subscriptionProduct) {
memberProducts.push(subscriptionProduct.toJSON());
}
} catch (e) {
this._logging.error(`Failed to attach products to member - ${data.id}`);
this._logging.error(e);
}
const isComplimentary = subscription.get('plan_nickname') && subscription.get('plan_nickname').toLowerCase() === 'complimentary';
if (status === 'comped' || isComplimentary) {
status = 'comped';
} else {
status = 'paid';
@ -362,7 +422,19 @@ module.exports = class MemberRepository {
}
}
}
const updatedMember = await this._Member.edit({status: status}, {...options, id: data.id});
let updatedMember;
try {
// Remove duplicate products from the list
memberProducts = _.uniqBy(memberProducts, function (e) {
return e.id;
});
// Edit member with updated products assoicated
updatedMember = await this._Member.edit({status: status, products: memberProducts}, {...options, id: data.id});
} catch (e) {
this._logging.error(`Failed to update member - ${data.id} - with related products`);
this._logging.error(e);
updatedMember = await this._Member.edit({status: status}, {...options, id: data.id});
}
if (updatedMember.attributes.status !== updatedMember._previousAttributes.status) {
await this._MemberStatusEvent.add({
member_id: data.id,

View File

@ -89,6 +89,8 @@ class ProductRepository {
* @param {object} data
* @param {string} data.name
* @param {StripePriceInput[]} data.stripe_prices
* @param {string} data.product_id
* @param {string} data.stripe_product_id
*
* @param {object} options
*
@ -184,6 +186,33 @@ class ProductRepository {
const defaultStripeProduct = product.related('stripeProducts').first();
const newPrices = data.stripe_prices.filter(price => !price.stripe_price_id);
const existingPrices = data.stripe_prices.filter((price) => {
return !!price.stripe_price_id && !!price.stripe_product_id;
});
for (const existingPrice of existingPrices) {
const productId = existingPrice.stripe_product_id;
let stripeProduct = await this._StripeProduct.findOne({stripe_product_id: productId}, options);
if (!stripeProduct) {
stripeProduct = await this._StripeProduct.add({
product_id: product.id,
stripe_product_id: productId
}, options);
}
const stripePrice = await this._StripePrice.findOne({stripe_price_id: existingPrice.stripe_price_id}, options);
if (!stripePrice) {
await this._StripePrice.add({
stripe_price_id: existingPrice.stripe_price_id,
stripe_product_id: stripeProduct.get('stripe_product_id'),
active: existingPrice.active,
nickname: existingPrice.nickname,
currency: existingPrice.currency,
amount: existingPrice.amount,
type: existingPrice.type,
interval: existingPrice.interval
}, options);
}
}
for (const newPrice of newPrices) {
const productId = newPrice.stripe_product_id;

View File

@ -435,6 +435,20 @@ module.exports = class StripeAPIService {
return this._config.publicKey;
}
/**
* getPrice
*
* @param {string} id
* @param {object} options
*
* @returns {Promise<import('stripe').Stripe.Price>}
*/
async getPrice(id, options = {}) {
debug(`getPrice(${id}, ${JSON.stringify(options)})`);
return await this._stripe.prices.retrieve(id, options);
}
/**
* getPlan
*