mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 05:37:34 +03:00
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:
parent
dc0e5b0ec8
commit
bee619c123
@ -90,6 +90,7 @@ module.exports = function MembersApi({
|
||||
stripeAPIService,
|
||||
stripePlansService,
|
||||
logger,
|
||||
productRepository,
|
||||
Member,
|
||||
MemberSubscribeEvent,
|
||||
MemberPaidSubscriptionEvent,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
*
|
||||
|
Loading…
Reference in New Issue
Block a user