Эх сурвалжийг харах

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

Add option to reactivate an add-on

GitOrigin-RevId: a1795f37dac5141996d626d87ba3a9bae1d218dd
Eric Mc Sween 11 сар өмнө
parent
commit
daba09c96f

+ 30 - 0
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
    *

+ 38 - 0
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,

+ 12 - 0
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,

+ 6 - 0
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(),

+ 3 - 0
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": "",

+ 2 - 0
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 &&

+ 61 - 17
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<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>

+ 3 - 0
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></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",

+ 86 - 37
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',
-          })
-        })
-
-        describe('hasAddOn()', function () {
-          it('returns false for any add-on', function () {
-            expect(this.subscription.hasAddOn('some-add-on')).to.be.false
-          })
+          this.subscription.pendingChange =
+            new PaymentProviderSubscriptionChange({
+              subscription: this.subscription,
+              nextPlanCode: this.subscription.planCode,
+              nextPlanName: this.subscription.planName,
+              nextPlanPrice: this.subscription.planPrice,
+              nextAddOns: [],
+            })
         })
 
-        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 () {