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

Prevent buying add-on while subscription is paused

GitOrigin-RevId: b8cfbbaa05a1031bedf37edf7b1ded2252eb6906
This commit is contained in:
roo hutton
2025-08-11 13:24:57 +01:00
committed by Copybot
parent ec0f719307
commit 6c185cd700
5 changed files with 467 additions and 0 deletions

View File

@@ -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<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 { 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,
}

View File

@@ -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": "",

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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 })
})
})
})