mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-28 19:41:33 +02:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user