Răsfoiți Sursa

Merge pull request #27670 from overleaf/rh-stripe-pause-addons

Prevent buying add-on while subscription is paused

GitOrigin-RevId: b8cfbbaa05a1031bedf37edf7b1ded2252eb6906
roo hutton 1 an în urmă
părinte
comite
6c185cd700

+ 108 - 0
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -41,6 +41,99 @@ const {
 } = require('../../infrastructure/FrontEndUser')
 } = require('../../infrastructure/FrontEndUser')
 const { IndeterminateInvoiceError } = require('../Errors/Errors')
 const { IndeterminateInvoiceError } = require('../Errors/Errors')
 
 
+const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
+  '/user/subscription?redirect-reason=subscription-paused'
+
+/**
+ * Check if a Stripe subscription is currently paused
+ * @param {Object} subscription - The subscription object
+ * @returns {Promise<boolean>}
+ */
+async function _checkStripeSubscriptionPauseStatus(subscription) {
+  if (
+    !subscription.paymentProvider?.service?.includes('stripe') ||
+    !subscription.paymentProvider.subscriptionId
+  ) {
+    return false
+  }
+
+  const [paymentRecord] = await Modules.promises.hooks.fire(
+    'getPaymentFromRecord',
+    subscription
+  )
+
+  return !!(
+    paymentRecord.subscription.remainingPauseCycles &&
+    paymentRecord.subscription.remainingPauseCycles > 0
+  )
+}
+
+/**
+ * Check if a Recurly subscription is currently paused
+ * @param {Object} subscription - The subscription object
+ * @returns {Promise<boolean>}
+ */
+async function _checkRecurlySubscriptionPauseStatus(subscription) {
+  if (!subscription.recurlySubscription_id) {
+    return false
+  }
+
+  if (subscription.recurlyStatus?.state === 'paused') {
+    return true
+  }
+
+  // Get the recurly subscription as this may be a pending pause
+  const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
+    subscription.recurlySubscription_id
+  )
+
+  return !!(
+    recurlySubscription.remaining_pause_cycles &&
+    recurlySubscription.remaining_pause_cycles > 0
+  )
+}
+
+/**
+ * Check if a user's subscription is currently paused
+ * @param {Object} user - The user object
+ * @returns {Promise<{isPaused: boolean, redirectPath?: string}>}
+ */
+async function checkSubscriptionPauseStatus(user) {
+  try {
+    const { subscription } =
+      await LimitationsManager.promises.userHasSubscription(user)
+
+    if (!subscription) {
+      return { isPaused: false }
+    }
+
+    const isStripePaused =
+      await _checkStripeSubscriptionPauseStatus(subscription)
+    if (isStripePaused) {
+      return {
+        isPaused: true,
+        redirectPath: SUBSCRIPTION_PAUSED_REDIRECT_PATH,
+      }
+    }
+
+    const isRecurlyPaused =
+      await _checkRecurlySubscriptionPauseStatus(subscription)
+    if (isRecurlyPaused) {
+      return {
+        isPaused: true,
+        redirectPath: SUBSCRIPTION_PAUSED_REDIRECT_PATH,
+      }
+    }
+  } catch (err) {
+    logger.warn(
+      { err, userId: user._id },
+      'Failed to check user subscription for pause status'
+    )
+  }
+
+  return { isPaused: false }
+}
+
 /**
 /**
  * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
  * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
  * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
  * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
@@ -367,6 +460,11 @@ async function previewAddonPurchase(req, res) {
     )
     )
   }
   }
 
 
+  const { isPaused, redirectPath } = await checkSubscriptionPauseStatus(user)
+  if (isPaused) {
+    return res.redirect(redirectPath)
+  }
+
   /** @type {PaymentMethod[]} */
   /** @type {PaymentMethod[]} */
   const paymentMethod = await Modules.promises.hooks.fire(
   const paymentMethod = await Modules.promises.hooks.fire(
     'getPaymentMethod',
     'getPaymentMethod',
@@ -434,6 +532,15 @@ async function purchaseAddon(req, res, next) {
     return res.sendStatus(404)
     return res.sendStatus(404)
   }
   }
 
 
+  const { isPaused } = await checkSubscriptionPauseStatus(user)
+  if (isPaused) {
+    return HttpErrorHandler.badRequest(
+      req,
+      res,
+      'Cannot purchase add-ons while subscription is paused.'
+    )
+  }
+
   logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
   logger.debug({ userId: user._id, addOnCode }, 'purchasing add-ons')
   try {
   try {
     await SubscriptionHandler.promises.purchaseAddon(
     await SubscriptionHandler.promises.purchaseAddon(
@@ -911,4 +1018,5 @@ module.exports = {
   getRecommendedCurrency,
   getRecommendedCurrency,
   getLatamCountryBannerDetails,
   getLatamCountryBannerDetails,
   getPlanNameForDisplay,
   getPlanNameForDisplay,
+  checkSubscriptionPauseStatus,
 }
 }

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -1114,6 +1114,7 @@
   "next_page": "",
   "next_page": "",
   "next_payment_of_x_collectected_on_y": "",
   "next_payment_of_x_collectected_on_y": "",
   "no_actions": "",
   "no_actions": "",
+  "no_add_on_purchase_while_paused": "",
   "no_borders": "",
   "no_borders": "",
   "no_caption": "",
   "no_caption": "",
   "no_comments_or_suggestions": "",
   "no_comments_or_suggestions": "",

+ 2 - 0
services/web/frontend/js/features/subscription/components/dashboard/redirect-alerts.tsx

@@ -17,6 +17,8 @@ export function RedirectAlerts() {
     warning = t('good_news_you_already_purchased_this_add_on')
     warning = t('good_news_you_already_purchased_this_add_on')
   } else if (redirectReason === 'ai-assist-unavailable') {
   } else if (redirectReason === 'ai-assist-unavailable') {
     warning = t('ai_assist_unavailable_due_to_subscription_type')
     warning = t('ai_assist_unavailable_due_to_subscription_type')
+  } else if (redirectReason === 'subscription-paused') {
+    warning = t('no_add_on_purchase_while_paused')
   } else {
   } else {
     return null
     return null
   }
   }

+ 1 - 0
services/web/locales/en.json

@@ -1447,6 +1447,7 @@
   "nl": "Dutch",
   "nl": "Dutch",
   "no": "Norwegian",
   "no": "Norwegian",
   "no_actions": "No actions",
   "no_actions": "No actions",
+  "no_add_on_purchase_while_paused": "You need to unpause or cancel your existing subscription to buy an add-on.",
   "no_articles_matching_your_tags": "There are no articles matching your tags",
   "no_articles_matching_your_tags": "There are no articles matching your tags",
   "no_borders": "No borders",
   "no_borders": "No borders",
   "no_caption": "No caption",
   "no_caption": "No caption",

+ 355 - 0
services/web/test/unit/src/Subscription/SubscriptionControllerTests.js

@@ -159,6 +159,7 @@ describe('SubscriptionController', function () {
         './RecurlyWrapper': (this.RecurlyWrapper = {
         './RecurlyWrapper': (this.RecurlyWrapper = {
           promises: {
           promises: {
             updateAccountEmailAddress: sinon.stub().resolves(),
             updateAccountEmailAddress: sinon.stub().resolves(),
+            getSubscription: sinon.stub().resolves({}),
           },
           },
         }),
         }),
         './RecurlyEventHandler': {
         './RecurlyEventHandler': {
@@ -744,4 +745,358 @@ describe('SubscriptionController', function () {
         .called
         .called
     })
     })
   })
   })
+
+  describe('checkSubscriptionPauseStatus', function () {
+    beforeEach(function () {
+      this.user = {
+        _id: 'user-id-123',
+        email: 'test@example.com',
+      }
+    })
+
+    it('should return isPaused: false when user has no subscription', async function () {
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription: null,
+      })
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when subscription has no paymentProvider', async function () {
+      const subscription = {
+        planCode: 'professional',
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when subscription has no subscriptionId', async function () {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: null,
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Stripe subscription has no remaining pause cycles', async function () {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {
+          remainingPauseCycles: 0,
+        },
+      }
+      this.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Stripe subscription has no remainingPauseCycles property', async function () {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {},
+      }
+      this.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: true with redirect path when Stripe subscription has remaining pause cycles', async function () {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {
+          remainingPauseCycles: 2,
+        },
+      }
+      this.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+    })
+
+    it('should return isPaused: true when remainingPauseCycles is exactly 1', async function () {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const paymentRecord = {
+        subscription: {
+          remainingPauseCycles: 1,
+        },
+      }
+      this.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .resolves([paymentRecord])
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+    })
+
+    it('should return isPaused: false when userHasSubscription throws error', async function () {
+      const error = new Error('Something bad happened')
+      this.LimitationsManager.promises.userHasSubscription.rejects(error)
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when getPaymentFromRecord throws error', async function () {
+      const subscription = {
+        paymentProvider: {
+          service: 'stripe',
+          subscriptionId: 'sub-123',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const error = new Error('Something bad happened')
+      this.Modules.promises.hooks.fire
+        .withArgs('getPaymentFromRecord', subscription)
+        .rejects(error)
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Recurly subscription is not paused', async function () {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: true when Recurly subscription is paused', async function () {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'paused',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+    })
+
+    it('should return isPaused: true when Recurly subscription has pending pause cycles', async function () {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const recurlySubscriptionData = {
+        remaining_pause_cycles: 2,
+      }
+      this.RecurlyWrapper.promises.getSubscription.resolves(
+        recurlySubscriptionData
+      )
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({
+        isPaused: true,
+        redirectPath: '/user/subscription?redirect-reason=subscription-paused',
+      })
+      expect(
+        this.RecurlyWrapper.promises.getSubscription
+      ).to.have.been.calledWith('uuid-123')
+    })
+
+    it('should return isPaused: false when Recurly subscription has no remaining pause cycles', async function () {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const recurlySubscriptionData = {
+        remaining_pause_cycles: 0,
+      }
+      this.RecurlyWrapper.promises.getSubscription.resolves(
+        recurlySubscriptionData
+      )
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Recurly subscription has no remaining_pause_cycles property', async function () {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const recurlySubscriptionData = {}
+      this.RecurlyWrapper.promises.getSubscription.resolves(
+        recurlySubscriptionData
+      )
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+
+    it('should return isPaused: false when Recurly API call fails', async function () {
+      const subscription = {
+        recurlySubscription_id: 'uuid-123',
+        recurlyStatus: {
+          state: 'active',
+        },
+      }
+      this.LimitationsManager.promises.userHasSubscription.resolves({
+        subscription,
+      })
+
+      const error = new Error('Recurly API failed')
+      this.RecurlyWrapper.promises.getSubscription.rejects(error)
+
+      const result =
+        await this.SubscriptionController.checkSubscriptionPauseStatus(
+          this.user
+        )
+
+      expect(result).to.deep.equal({ isPaused: false })
+    })
+  })
 })
 })