diff --git a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js index 69fd046eec..1d0245dce5 100644 --- a/services/web/app/src/Features/Subscription/PaymentProviderEntities.js +++ b/services/web/app/src/Features/Subscription/PaymentProviderEntities.js @@ -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 * diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js index 0050ca9487..b85de0d6bf 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionController.js +++ b/services/web/app/src/Features/Subscription/SubscriptionController.js @@ -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, diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js index 4386ad994b..2bb0589818 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js +++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js @@ -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, diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs index 232c7ab1ab..9b7649128c 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs @@ -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(), diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 6af3dfd775..7d882c14dd 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx index 6bcc5d9518..414fad677c 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/active.tsx @@ -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 && diff --git a/services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx b/services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx index d94d7ce068..06873d4b99 100644 --- a/services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx +++ b/services/web/frontend/js/features/subscription/components/dashboard/states/active/add-ons.tsx @@ -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('ready') + + const handleReactivateClick = (addOnCode: string) => { + setReactivateState('reactivating') + postJSON(`/user/subscription/addon/${addOnCode}/reactivate`) + .then(() => { + location.reload() + }) + .catch(err => { + debugConsole.error(err) + setReactivateState('error') + }) + } + return (
@@ -60,17 +83,28 @@ function AddOn({
{resolveAddOnName(addOnCode)}
- {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')} + + ) : 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 === 'error' && ( +
+ {t('reactivate_add_on_failed')} +
+ )}
- {!pendingCancellation && ( + {reactivateState !== 'reactivating' && (
- handleCancelClick(addOnCode)} - as="button" - tabIndex={-1} - variant="danger" - > - {t('cancel')} - + {pendingCancellation ? ( + handleReactivateClick(addOnCode)} + as="button" + tabIndex={-1} + > + {t('reactivate')} + + ) : ( + handleCancelClick(addOnCode)} + as="button" + tabIndex={-1} + variant="danger" + > + {t('cancel')} + + )}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json index b7589f920a..b48af8ec54 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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> 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.", "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", diff --git a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js index 7b2b85b4d8..da688c823e 100644 --- a/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js +++ b/services/web/test/unit/src/Subscription/PaymentProviderEntitiesTest.js @@ -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 () {