From f74b00fea6e985fdd2be7d530ec44e71cbd69f39 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Tue, 19 Apr 2022 09:15:33 +0200 Subject: [PATCH] Stored offer_id in subscriptions (#389) refs https://github.com/TryGhost/Team/issues/1519 - Added offer repository dependency to member repository (offerAPI didn't work because it creates a new transaction that resulted in a deadlock during tests) - Store the offer id from the Stripe subscription metadata in the subscription (only if the discount is still active) - Also added the offer id to the metadata for a Stripe coupon, this will make adding and removing coupons a bit more foolproof - Prefer the usage of the offer metadata from a coupon if it is present - When no discount is applied to a subscription, it always sets the offer id to null, even when the metadata still contains the offer - The offer_id remains stored when a subscription is canceled/expired --- ghost/members-api/lib/MembersAPI.js | 3 ++- ghost/members-api/lib/repositories/member.js | 27 +++++++++++++++++--- ghost/payments/lib/payments.js | 6 ++++- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/ghost/members-api/lib/MembersAPI.js b/ghost/members-api/lib/MembersAPI.js index b6917cd282..62243a53ff 100644 --- a/ghost/members-api/lib/MembersAPI.js +++ b/ghost/members-api/lib/MembersAPI.js @@ -92,7 +92,8 @@ module.exports = function MembersAPI({ MemberProductEvent, OfferRedemption, StripeCustomer, - StripeCustomerSubscription + StripeCustomerSubscription, + offerRepository: offersAPI.repository }); const eventRepository = new EventRepository({ diff --git a/ghost/members-api/lib/repositories/member.js b/ghost/members-api/lib/repositories/member.js index 00085c9ad2..b572830711 100644 --- a/ghost/members-api/lib/repositories/member.js +++ b/ghost/members-api/lib/repositories/member.js @@ -33,11 +33,13 @@ module.exports = class MemberRepository { * @param {any} deps.MemberProductEvent * @param {any} deps.StripeCustomer * @param {any} deps.StripeCustomerSubscription - * @param {any} deps.productRepository - * @param {any} deps.newslettersService - * @param {any} deps.labsService + * @param {any} deps.OfferRedemption * @param {import('../../services/stripe-api')} deps.stripeAPIService + * @param {any} deps.labsService + * @param {any} deps.productRepository + * @param {any} deps.offerRepository * @param {ITokenService} deps.tokenService + * @param {any} deps.newslettersService */ constructor({ Member, @@ -53,6 +55,7 @@ module.exports = class MemberRepository { stripeAPIService, labsService, productRepository, + offerRepository, tokenService, newslettersService }) { @@ -67,6 +70,7 @@ module.exports = class MemberRepository { this._StripeCustomerSubscription = StripeCustomerSubscription; this._stripeAPIService = stripeAPIService; this._productRepository = productRepository; + this._offerRepository = offerRepository; this.tokenService = tokenService; this._newslettersService = newslettersService; this._labsService = labsService; @@ -698,6 +702,17 @@ module.exports = class MemberRepository { logging.error(e); } + let offerId = subscription.discount && (subscription.discount.coupon.metadata.offer || subscription.metadata.offer) ? (subscription.discount.coupon.metadata.offer ? subscription.discount.coupon.metadata.offer : subscription.metadata.offer) : null; + + if (offerId) { + // Validate the offer id from the metadata + const offer = await this._offerRepository.getById(offerId, {transacting: options.transacting}); + if (!offer) { + logging.error(`Received an invalid offer id (${offerId}) in the metadata of a subscription - ${subscription.id}.`); + offerId = null; + } + } + const subscriptionData = { customer_id: subscription.customer, subscription_id: subscription.id, @@ -723,8 +738,12 @@ module.exports = class MemberRepository { status: subscription.status, canceled: subscription.cancel_at_period_end, discount: subscription.discount - }) + }), + // We try to use the offer_id that was stored in Stripe coupon and fallback to the one stored in the subscription + // This allows us to catch the offer_id from discounts (created by Ghost) that were applied via the Stripe dashboard + offer_id: offerId }; + let eventData = {}; if (model) { const updated = await this._StripeCustomerSubscription.edit(subscriptionData, { diff --git a/ghost/payments/lib/payments.js b/ghost/payments/lib/payments.js index a87e1df093..3130df19cf 100644 --- a/ghost/payments/lib/payments.js +++ b/ghost/payments/lib/payments.js @@ -48,7 +48,11 @@ class PaymentsService { /** @type {import('stripe').Stripe.CouponCreateParams} */ const couponData = { name: offer.name, - duration: offer.duration + duration: offer.duration, + // Note that the metadata is not present for older coupons + metadata: { + offer: offer.id + } }; if (offer.duration === 'repeating') {