From 6c185cd700927a007a2f4eed82edf3cd016184bc Mon Sep 17 00:00:00 2001 From: roo hutton Date: Mon, 11 Aug 2025 13:24:57 +0100 Subject: [PATCH] Merge pull request #27670 from overleaf/rh-stripe-pause-addons Prevent buying add-on while subscription is paused GitOrigin-RevId: b8cfbbaa05a1031bedf37edf7b1ded2252eb6906 --- .../Subscription/SubscriptionController.js | 108 ++++++ .../web/frontend/extracted-translations.json | 1 + .../components/dashboard/redirect-alerts.tsx | 2 + services/web/locales/en.json | 1 + .../SubscriptionControllerTests.js | 355 ++++++++++++++++++ 5 files changed, 467 insertions(+) diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index def0abf7ac..65190e7774 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -41,6 +41,99 @@ const { } = require('../../infrastructure/FrontEndUser') 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} + */ +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} + */ +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 { 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[]} */ const paymentMethod = await Modules.promises.hooks.fire( 'getPaymentMethod', @@ -434,6 +532,15 @@ async function purchaseAddon(req, res, next) { 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') try { await SubscriptionHandler.promises.purchaseAddon( @@ -911,4 +1018,5 @@ module.exports = { getRecommendedCurrency, getLatamCountryBannerDetails, getPlanNameForDisplay, + checkSubscriptionPauseStatus, } diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 98ec3b3dba..567e48eea4 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1114,6 +1114,7 @@ "next_page": "", "next_payment_of_x_collectected_on_y": "", "no_actions": "", + "no_add_on_purchase_while_paused": "", "no_borders": "", "no_caption": "", "no_comments_or_suggestions": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/redirect-alerts.tsx b/services/web/frontend/js/features/subscription/components/dashboard/redirect-alerts.tsx index d1e12bd79d..826d4eecc3 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/redirect-alerts.tsx +++ b/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') } else if (redirectReason === 'ai-assist-unavailable') { warning = t('ai_assist_unavailable_due_to_subscription_type') + } else if (redirectReason === 'subscription-paused') { + warning = t('no_add_on_purchase_while_paused') } else { return null } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 3a30f68e73..4edd85c649 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1447,6 +1447,7 @@ "nl": "Dutch", "no": "Norwegian", "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_borders": "No borders", "no_caption": "No caption", diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js index 24ce1bce8b..b397adb21c 100644 --- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js +++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js @@ -159,6 +159,7 @@ describe('SubscriptionController', function () { './RecurlyWrapper': (this.RecurlyWrapper = { promises: { updateAccountEmailAddress: sinon.stub().resolves(), + getSubscription: sinon.stub().resolves({}), }, }), './RecurlyEventHandler': { @@ -744,4 +745,358 @@ describe('SubscriptionController', function () { .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 }) + }) + }) })