Merge pull request #27999 from overleaf/em-reactivate-add-on

Add option to reactivate an add-on

GitOrigin-RevId: a1795f37dac5141996d626d87ba3a9bae1d218dd
This commit is contained in:
Eric Mc Sween
2025-09-10 07:20:30 -04:00
committed by Copybot
parent ae9d84c279
commit daba09c96f
9 changed files with 241 additions and 54 deletions

View File

@@ -271,6 +271,36 @@ class PaymentProviderSubscription {
})
}
/**
* Reactivate an add-on on this subscription
*
* @param {string} code - add-on code
* @return {PaymentProviderSubscriptionChangeRequest}
*
* @throws {AddOnNotPresentError} if the add-on is not pending cancellation
*/
getRequestForAddOnReactivation(code) {
const reactivatedAddOn = this.addOns.find(addOn => addOn.code === code)
const pendingChange = this.pendingChange
if (reactivatedAddOn == null || pendingChange == null) {
throw new AddOnNotPresentError('Add-on is not pending cancellation', {
subscriptionId: this.id,
addOnCode: code,
})
}
const addOnUpdates = pendingChange.nextAddOns
.filter(addOn => addOn.code !== code)
.map(addOn => addOn.toAddOnUpdate())
addOnUpdates.push(reactivatedAddOn.toAddOnUpdate())
return new PaymentProviderSubscriptionChangeRequest({
subscription: this,
timeframe: 'term_end',
addOnUpdates,
})
}
/**
* Form a request to revert the plan to it's last saved backup state
*

View File

@@ -690,6 +690,43 @@ async function removeAddon(req, res, next) {
}
}
const reactivateAddonSchema = z.object({
params: z.object({
addOnCode: z.string(),
}),
})
/**
* Reactivate an add-on pending cancellation
*
* This "cancels" the cancellation.
*/
async function reactivateAddon(req, res) {
const user = SessionManager.getSessionUser(req.session)
const { params } = validateReq(req, reactivateAddonSchema)
const addOnCode = params.addOnCode
if (addOnCode !== AI_ADD_ON_CODE) {
return res.sendStatus(404)
}
try {
await SubscriptionHandler.promises.reactivateAddon(user._id, addOnCode)
res.sendStatus(200)
} catch (err) {
if (err instanceof AddOnNotPresentError) {
HttpErrorHandler.badRequest(
req,
res,
'The requested add-on is not pending cancellation',
{ addon: addOnCode }
)
} else {
throw err
}
}
}
async function previewSubscription(req, res, next) {
const planCode = req.query.planCode
if (!planCode) {
@@ -1071,6 +1108,7 @@ module.exports = {
previewAddonPurchase: expressify(previewAddonPurchase),
purchaseAddon,
removeAddon,
reactivateAddon,
makeChangePreview,
getRecommendedCurrency,
getLatamCountryBannerDetails,

View File

@@ -285,6 +285,16 @@ async function removeAddon(userId, addOnCode) {
await Modules.promises.hooks.fire('removeAddOn', userId, addOnCode)
}
/**
* Reactivates an add-on pending cancellation
*
* @param {string} userId
* @param {string} addOnCode
*/
async function reactivateAddon(userId, addOnCode) {
await Modules.promises.hooks.fire('reactivateAddOn', userId, addOnCode)
}
async function pauseSubscription(user, pauseCycles) {
// only allow pausing on monthly plans not in a trial
const { subscription } =
@@ -419,6 +429,7 @@ module.exports = {
previewAddonPurchase: callbackify(previewAddonPurchase),
purchaseAddon: callbackify(purchaseAddon),
removeAddon: callbackify(removeAddon),
reactivateAddon: callbackify(reactivateAddon),
pauseSubscription: callbackify(pauseSubscription),
resumeSubscription: callbackify(resumeSubscription),
revertPlanChange: callbackify(revertPlanChange),
@@ -438,6 +449,7 @@ module.exports = {
previewAddonPurchase,
purchaseAddon,
removeAddon,
reactivateAddon,
pauseSubscription,
resumeSubscription,
revertPlanChange,

View File

@@ -185,6 +185,12 @@ export default {
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionController.removeAddon
)
webRouter.post(
'/user/subscription/addon/:addOnCode/reactivate',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
SubscriptionController.reactivateAddon
)
webRouter.post(
'/user/subscription/cancel-pending',
AuthenticationController.requireLogin(),

View File

@@ -1365,7 +1365,10 @@
"raw_logs_description": "",
"react_history_tutorial_content": "",
"react_history_tutorial_title": "",
"reactivate": "",
"reactivate_add_on_failed": "",
"reactivate_subscription": "",
"reactivating": "",
"read_lines_from_path": "",
"read_more": "",
"read_more_about_compile_timeout_changes": "",

View File

@@ -72,6 +72,7 @@ export function ActiveSubscription({
}
const handlePlanChange = () => setModalIdShown('change-plan')
const handleCancelClick = (addOnCode: string) => {
if (
[AI_ASSIST_STANDALONE_MONTHLY_PLAN_CODE, AI_ADD_ON_CODE].includes(
@@ -81,6 +82,7 @@ export function ActiveSubscription({
setModalIdShown('cancel-ai-add-on')
}
}
const hasPendingPause = Boolean(
subscription.payment.state === 'active' &&
subscription.payment.remainingPauseCycles &&

View File

@@ -1,8 +1,13 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import { Dropdown, DropdownMenu, DropdownToggle } from 'react-bootstrap'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import MaterialIcon from '@/shared/components/material-icon'
import { useLocation } from '@/shared/hooks/use-location'
import {
ADD_ON_NAME,
AI_ADD_ON_CODE,
@@ -38,6 +43,8 @@ function resolveAddOnName(addOnCode: string) {
}
}
type ReactivateState = 'ready' | 'reactivating' | 'error'
function AddOn({
addOnCode,
displayPrice,
@@ -47,6 +54,22 @@ function AddOn({
nextBillingDate,
}: AddOnProps) {
const { t } = useTranslation()
const location = useLocation()
const [reactivateState, setReactivateState] =
useState<ReactivateState>('ready')
const handleReactivateClick = (addOnCode: string) => {
setReactivateState('reactivating')
postJSON(`/user/subscription/addon/${addOnCode}/reactivate`)
.then(() => {
location.reload()
})
.catch(err => {
debugConsole.error(err)
setReactivateState('error')
})
}
return (
<div className="add-on-card">
<div>
@@ -60,17 +83,28 @@ function AddOn({
<div className="add-on-card-content">
<div className="heading">{resolveAddOnName(addOnCode)}</div>
<div className="description small mt-1">
{pendingCancellation
? t(
'your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on',
{ nextBillingDate }
)
: isAnnual
? t('x_price_per_year', { price: displayPrice })
: t('x_price_per_month', { price: displayPrice })}
{reactivateState === 'reactivating' ? (
<>
{t('reactivating')} <OLSpinner size="sm" />
</>
) : pendingCancellation ? (
t(
'your_add_on_has_been_cancelled_and_will_remain_active_until_your_billing_cycle_ends_on',
{ nextBillingDate }
)
) : isAnnual ? (
t('x_price_per_year', { price: displayPrice })
) : (
t('x_price_per_month', { price: displayPrice })
)}
</div>
{reactivateState === 'error' && (
<div className="small mt-1 text-danger">
{t('reactivate_add_on_failed')}
</div>
)}
</div>
{!pendingCancellation && (
{reactivateState !== 'reactivating' && (
<div className="ms-auto">
<Dropdown align="end">
<DropdownToggle
@@ -84,14 +118,24 @@ function AddOn({
/>
</DropdownToggle>
<DropdownMenu flip={false}>
<OLDropdownMenuItem
onClick={() => handleCancelClick(addOnCode)}
as="button"
tabIndex={-1}
variant="danger"
>
{t('cancel')}
</OLDropdownMenuItem>
{pendingCancellation ? (
<OLDropdownMenuItem
onClick={() => handleReactivateClick(addOnCode)}
as="button"
tabIndex={-1}
>
{t('reactivate')}
</OLDropdownMenuItem>
) : (
<OLDropdownMenuItem
onClick={() => handleCancelClick(addOnCode)}
as="button"
tabIndex={-1}
variant="danger"
>
{t('cancel')}
</OLDropdownMenuItem>
)}
</DropdownMenu>
</Dropdown>
</div>

View File

@@ -1779,7 +1779,10 @@
"raw_logs_description": "Raw logs from the LaTeX compiler",
"react_history_tutorial_content": "To compare a range of versions, use the <0></0> on the versions you want at the start and end of the range. To add a label or to download a version use the options in the three-dot menu. <1>Learn more about using Overleaf History.</1>",
"react_history_tutorial_title": "History actions have a new home",
"reactivate": "Reactivate",
"reactivate_add_on_failed": "Something went wrong while reactivating your add-on. Please try again later.",
"reactivate_subscription": "Reactivate your subscription",
"reactivating": "Reactivating",
"read_lines_from_path": "Read lines from __path__",
"read_more": "Read more",
"read_more_about_compile_timeout_changes": "Read more about changes to compile timeout limits",

View File

@@ -403,6 +403,14 @@ describe('PaymentProviderEntities', function () {
})
})
describe('getRequestForAddOnReactivation()', function () {
it('throws an AddOnNotPresentError', function () {
expect(() =>
this.subscription.getRequestForAddOnReactivation(this.addOn.code)
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForGroupPlanUpgrade()', function () {
it('returns a correct change request', function () {
const changeRequest =
@@ -454,61 +462,102 @@ describe('PaymentProviderEntities', function () {
})
})
describe('without add-ons', function () {
describe('with an add-on pending cancellation', function () {
beforeEach(function () {
const { PaymentProviderSubscription } = this.PaymentProviderEntities
this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
planName: 'My Plan',
planPrice: 10,
subtotal: 10.99,
taxRate: 0.2,
taxAmount: 2.4,
total: 14.4,
currency: 'USD',
})
this.subscription.pendingChange =
new PaymentProviderSubscriptionChange({
subscription: this.subscription,
nextPlanCode: this.subscription.planCode,
nextPlanName: this.subscription.planName,
nextPlanPrice: this.subscription.planPrice,
nextAddOns: [],
})
})
describe('hasAddOn()', function () {
it('returns false for any add-on', function () {
expect(this.subscription.hasAddOn('some-add-on')).to.be.false
})
})
describe('getRequestForAddOnPurchase()', function () {
describe('getRequestForAddOnReactivation()', function () {
it('returns a change request', function () {
const {
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionAddOnUpdate,
} = this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('some-add-on')
this.subscription.getRequestForAddOnReactivation(this.addOn.code)
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'some-add-on',
quantity: 1,
}),
],
timeframe: 'term_end',
addOnUpdates: [this.addOn.toAddOnUpdate()],
})
)
})
})
describe('getRequestForAddOnRemoval()', function () {
it('throws an AddOnNotPresentError', function () {
it('throws an AddOnNotPresentError if given the wrong add-on', function () {
expect(() =>
this.subscription.getRequestForAddOnRemoval('some-add-on')
this.subscription.getRequestForAddOnReactivation('some-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
})
})
})
describe('without add-ons', function () {
beforeEach(function () {
const { PaymentProviderSubscription } = this.PaymentProviderEntities
this.subscription = new PaymentProviderSubscription({
id: 'subscription-id',
userId: 'user-id',
planCode: 'regular-plan',
planName: 'My Plan',
planPrice: 10,
subtotal: 10.99,
taxRate: 0.2,
taxAmount: 2.4,
total: 14.4,
currency: 'USD',
})
})
describe('hasAddOn()', function () {
it('returns false for any add-on', function () {
expect(this.subscription.hasAddOn('some-add-on')).to.be.false
})
})
describe('getRequestForAddOnPurchase()', function () {
it('returns a change request', function () {
const {
PaymentProviderSubscriptionChangeRequest,
PaymentProviderSubscriptionAddOnUpdate,
} = this.PaymentProviderEntities
const changeRequest =
this.subscription.getRequestForAddOnPurchase('some-add-on')
expect(changeRequest).to.deep.equal(
new PaymentProviderSubscriptionChangeRequest({
subscription: this.subscription,
timeframe: 'now',
addOnUpdates: [
new PaymentProviderSubscriptionAddOnUpdate({
code: 'some-add-on',
quantity: 1,
}),
],
})
)
})
})
describe('getRequestForAddOnRemoval()', function () {
it('throws an AddOnNotPresentError', function () {
expect(() =>
this.subscription.getRequestForAddOnRemoval('some-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
})
describe('getRequestForAddOnReactivation()', function () {
it('throws an AddOnNotPresentError', function () {
expect(() =>
this.subscription.getRequestForAddOnReactivation('some-add-on')
).to.throw(Errors.AddOnNotPresentError)
})
})
})
})
describe('PaymentProviderSubscriptionChange', function () {