diff --git a/ghost/members-api/index.js b/ghost/members-api/index.js index 2135c6b7f1..ef5638c273 100644 --- a/ghost/members-api/index.js +++ b/ghost/members-api/index.js @@ -90,6 +90,7 @@ module.exports = function MembersApi({ stripeAPIService, stripePlansService, logger, + productRepository, Member, MemberSubscribeEvent, MemberPaidSubscriptionEvent, diff --git a/ghost/members-api/lib/repositories/member/index.js b/ghost/members-api/lib/repositories/member/index.js index ae790f6f16..c8a834e572 100644 --- a/ghost/members-api/lib/repositories/member/index.js +++ b/ghost/members-api/lib/repositories/member/index.js @@ -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, diff --git a/ghost/members-api/lib/repositories/product/index.js b/ghost/members-api/lib/repositories/product/index.js index ba0b905a20..4eb46f22ae 100644 --- a/ghost/members-api/lib/repositories/product/index.js +++ b/ghost/members-api/lib/repositories/product/index.js @@ -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; diff --git a/ghost/members-api/lib/services/stripe-api/index.js b/ghost/members-api/lib/services/stripe-api/index.js index 55b04acf4e..eb05d42f2d 100644 --- a/ghost/members-api/lib/services/stripe-api/index.js +++ b/ghost/members-api/lib/services/stripe-api/index.js @@ -435,6 +435,20 @@ module.exports = class StripeAPIService { return this._config.publicKey; } + /** + * getPrice + * + * @param {string} id + * @param {object} options + * + * @returns {Promise} + */ + async getPrice(id, options = {}) { + debug(`getPrice(${id}, ${JSON.stringify(options)})`); + + return await this._stripe.prices.retrieve(id, options); + } + /** * getPlan *