Просмотр исходного кода

Merge pull request #21839 from overleaf/em-subscription-change-preview-premium

Subscription preview for users with standalone AI add-on

GitOrigin-RevId: 636fa5aca8538bb95e79040d5c309dc505cfb17a
Eric Mc Sween 1 год назад
Родитель
Сommit
424fd5b591

+ 27 - 0
package-lock.json

@@ -41611,6 +41611,7 @@
         "mocha": "^10.2.0",
         "mocha-each": "^2.0.1",
         "mock-fs": "^5.1.2",
+        "nock": "^13.5.6",
         "nvd3": "^1.8.6",
         "overleaf-editor-core": "*",
         "pdfjs-dist": "4.6.82",
@@ -43736,6 +43737,20 @@
         "@sinonjs/commons": "^1.7.0"
       }
     },
+    "services/web/node_modules/nock": {
+      "version": "13.5.6",
+      "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz",
+      "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.1.0",
+        "json-stringify-safe": "^5.0.1",
+        "propagate": "^2.0.0"
+      },
+      "engines": {
+        "node": ">= 10.13"
+      }
+    },
     "services/web/node_modules/on-finished": {
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -51174,6 +51189,7 @@
         "mongoose": "8.5.3",
         "multer": "overleaf/multer#e1df247fbf8e7590520d20ae3601eaef9f3d2e9e",
         "nocache": "^2.1.0",
+        "nock": "^13.5.6",
         "node-fetch": "^2.7.0",
         "nodemailer": "^6.7.0",
         "nodemailer-ses-transport": "^1.5.1",
@@ -52744,6 +52760,17 @@
             }
           }
         },
+        "nock": {
+          "version": "13.5.6",
+          "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz",
+          "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==",
+          "dev": true,
+          "requires": {
+            "debug": "^4.1.0",
+            "json-stringify-safe": "^5.0.1",
+            "propagate": "^2.0.0"
+          }
+        },
         "on-finished": {
           "version": "2.4.1",
           "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",

+ 58 - 0
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -13,6 +13,7 @@ const {
   PaypalPaymentMethod,
   CreditCardPaymentMethod,
   RecurlyAddOn,
+  RecurlyPlan,
 } = require('./RecurlyEntities')
 
 /**
@@ -67,6 +68,32 @@ async function getSubscription(subscriptionId) {
   return subscriptionFromApi(subscription)
 }
 
+/**
+ * Get the subscription for a given user
+ *
+ * @param {string} userId
+ * @return {Promise<RecurlySubscription | null>}
+ */
+async function getSubscriptionForUser(userId) {
+  const subscriptions = await client.listAccountSubscriptions(
+    `code-${userId}`,
+    { params: { state: 'active', limit: 2 } }
+  )
+  let result = null
+  for await (const subscription of subscriptions.each()) {
+    if (result != null) {
+      throw new OError('User has more than one Recurly subscription', {
+        userId,
+      })
+    }
+    result = subscription
+  }
+  if (result == null) {
+    return null
+  }
+  return subscriptionFromApi(result)
+}
+
 /**
  * Request a susbcription change from Recurly
  *
@@ -161,6 +188,17 @@ async function getAddOn(planCode, addOnCode) {
   return addOnFromApi(addOn)
 }
 
+/**
+ * Get the configuration for a given plan
+ *
+ * @param {string} planCode
+ * @return {Promise<RecurlyPlan>}
+ */
+async function getPlan(planCode) {
+  const plan = await client.getPlan(`code-${planCode}`)
+  return planFromApi(plan)
+}
+
 function subscriptionIsCanceledOrExpired(subscription) {
   const state = subscription?.recurlyStatus?.state
   return state === 'canceled' || state === 'expired'
@@ -303,6 +341,22 @@ function addOnFromApi(addOn) {
   })
 }
 
+/**
+ * Build a RecurlyPlan from Recurly API data
+ *
+ * @param {recurly.Plan} plan
+ * @return {RecurlyPlan}
+ */
+function planFromApi(plan) {
+  if (plan.code == null || plan.name == null) {
+    throw new OError('Invalid Recurly add-on', { plan })
+  }
+  return new RecurlyPlan({
+    code: plan.code,
+    name: plan.name,
+  })
+}
+
 /**
  * Build an API request from a RecurlySubscriptionChangeRequest
  *
@@ -339,6 +393,7 @@ module.exports = {
   getAccountForUserId: callbackify(getAccountForUserId),
   createAccountForUserId: callbackify(createAccountForUserId),
   getSubscription: callbackify(getSubscription),
+  getSubscriptionForUser: callbackify(getSubscriptionForUser),
   previewSubscriptionChange: callbackify(previewSubscriptionChange),
   applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
   removeSubscriptionChange: callbackify(removeSubscriptionChange),
@@ -347,10 +402,12 @@ module.exports = {
   cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
   getPaymentMethod: callbackify(getPaymentMethod),
   getAddOn: callbackify(getAddOn),
+  getPlan: callbackify(getPlan),
   subscriptionIsCanceledOrExpired,
 
   promises: {
     getSubscription,
+    getSubscriptionForUser,
     getAccountForUserId,
     createAccountForUserId,
     previewSubscriptionChange,
@@ -361,5 +418,6 @@ module.exports = {
     cancelSubscriptionByUuid,
     getPaymentMethod,
     getAddOn,
+    getPlan,
   },
 }

+ 36 - 0
services/web/app/src/Features/Subscription/RecurlyEntities.js

@@ -6,6 +6,7 @@ const PlansLocator = require('./PlansLocator')
 const SubscriptionHelper = require('./SubscriptionHelper')
 
 const AI_ADD_ON_CODE = 'assistant'
+const STANDALONE_AI_ADD_ON_CODES = ['assistant', 'assistant-annual']
 
 class RecurlySubscription {
   /**
@@ -44,6 +45,15 @@ class RecurlySubscription {
     return this.addOns.some(addOn => addOn.code === code)
   }
 
+  /**
+   * Returns whether this subscription is a standalone AI add-on subscription
+   *
+   * @return {boolean}
+   */
+  isStandaloneAiAddOn() {
+    return isStandaloneAiAddOnPlanCode(this.planCode)
+  }
+
   /**
    * Change this subscription's plan
    *
@@ -262,6 +272,30 @@ class RecurlyAddOn {
   }
 }
 
+/**
+ * A plan configuration
+ */
+class RecurlyPlan {
+  /**
+   * @param {object} props
+   * @param {string} props.code
+   * @param {string} props.name
+   */
+  constructor(props) {
+    this.code = props.code
+    this.name = props.name
+  }
+}
+
+/**
+ * Returns whether the given plan code is a standalone AI plan
+ *
+ * @param {string} planCode
+ */
+function isStandaloneAiAddOnPlanCode(planCode) {
+  return STANDALONE_AI_ADD_ON_CODES.includes(planCode)
+}
+
 module.exports = {
   AI_ADD_ON_CODE,
   RecurlySubscription,
@@ -272,4 +306,6 @@ module.exports = {
   PaypalPaymentMethod,
   CreditCardPaymentMethod,
   RecurlyAddOn,
+  RecurlyPlan,
+  isStandaloneAiAddOnPlanCode,
 }

+ 7 - 0
services/web/app/src/Features/Subscription/RecurlyWrapper.js

@@ -10,6 +10,10 @@ const Errors = require('../Errors/Errors')
 const SubscriptionErrors = require('./Errors')
 const { callbackify } = require('@overleaf/promise-utils')
 
+/**
+ * @param accountId
+ * @param newEmail
+ */
 async function updateAccountEmailAddress(accountId, newEmail) {
   const data = {
     email: newEmail,
@@ -814,6 +818,9 @@ const promises = {
     }
   },
 
+  /**
+   * @param xml
+   */
   _parseXml(xml) {
     function convertDataTypes(data) {
       let key, value

+ 76 - 26
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -31,7 +31,10 @@ const RecurlyClient = require('./RecurlyClient')
 const { AI_ADD_ON_CODE } = require('./RecurlyEntities')
 
 /**
+ * @import { SubscriptionChangeDescription } from '../../../../types/subscription/subscription-change-preview'
  * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
+ * @import { RecurlySubscriptionChange } from './RecurlyEntities'
+ * @import { PaymentMethod } from './types'
  */
 
 const groupPlanModalOptions = Settings.groupPlanModalOptions
@@ -516,38 +519,17 @@ async function previewAddonPurchase(req, res) {
   )
 
   /** @type {SubscriptionChangePreview} */
-  const changePreview = {
-    change: {
+  const changePreview = makeChangePreview(
+    {
       type: 'add-on-purchase',
       addOn: {
         code: addOn.code,
         name: addOn.name,
       },
     },
-    currency: subscription.currency,
-    immediateCharge: subscriptionChange.immediateCharge,
-    paymentMethod: paymentMethod.toString(),
-    nextInvoice: {
-      date: subscription.periodEnd.toISOString(),
-      plan: {
-        name: subscriptionChange.nextPlanName,
-        amount: subscriptionChange.nextPlanPrice,
-      },
-      addOns: subscriptionChange.nextAddOns.map(addOn => ({
-        code: addOn.code,
-        name: addOn.name,
-        quantity: addOn.quantity,
-        unitAmount: addOn.unitPrice,
-        amount: addOn.preTaxTotal,
-      })),
-      subtotal: subscriptionChange.subtotal,
-      tax: {
-        rate: subscription.taxRate,
-        amount: subscriptionChange.tax,
-      },
-      total: subscriptionChange.total,
-    },
-  }
+    subscriptionChange,
+    paymentMethod
+  )
 
   res.render('subscriptions/preview-change', { changePreview })
 }
@@ -619,6 +601,31 @@ async function removeAddon(req, res, next) {
   }
 }
 
+async function previewSubscription(req, res, next) {
+  const planCode = req.query.planCode
+  if (!planCode) {
+    return HttpErrorHandler.notFound(req, res, 'Missing plan code')
+  }
+  const plan = await RecurlyClient.promises.getPlan(planCode)
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const subscriptionChange =
+    await SubscriptionHandler.promises.previewSubscriptionChange(
+      userId,
+      planCode
+    )
+  const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
+  const changePreview = makeChangePreview(
+    {
+      type: 'premium-subscription',
+      plan: { code: plan.code, name: plan.name },
+    },
+    subscriptionChange,
+    paymentMethod
+  )
+
+  res.render('subscriptions/preview-change', { changePreview })
+}
+
 function updateSubscription(req, res, next) {
   const origin = req && req.query ? req.query.origin : null
   const user = SessionManager.getSessionUser(req.session)
@@ -887,6 +894,48 @@ async function getLatamCountryBannerDetails(req, res) {
   return latamCountryBannerDetails
 }
 
+/**
+ * Build a subscription change preview for display purposes
+ *
+ * @param {SubscriptionChangeDescription} subscriptionChangeDescription A description of the change for the frontend
+ * @param {RecurlySubscriptionChange} subscriptionChange The subscription change object coming from Recurly
+ * @param {PaymentMethod} paymentMethod The payment method associated to the user
+ * @return {SubscriptionChangePreview}
+ */
+function makeChangePreview(
+  subscriptionChangeDescription,
+  subscriptionChange,
+  paymentMethod
+) {
+  const subscription = subscriptionChange.subscription
+  return {
+    change: subscriptionChangeDescription,
+    currency: subscription.currency,
+    immediateCharge: subscriptionChange.immediateCharge,
+    paymentMethod: paymentMethod.toString(),
+    nextInvoice: {
+      date: subscription.periodEnd.toISOString(),
+      plan: {
+        name: subscriptionChange.nextPlanName,
+        amount: subscriptionChange.nextPlanPrice,
+      },
+      addOns: subscriptionChange.nextAddOns.map(addOn => ({
+        code: addOn.code,
+        name: addOn.name,
+        quantity: addOn.quantity,
+        unitAmount: addOn.unitPrice,
+        amount: addOn.preTaxTotal,
+      })),
+      subtotal: subscriptionChange.subtotal,
+      tax: {
+        rate: subscription.taxRate,
+        amount: subscriptionChange.tax,
+      },
+      total: subscriptionChange.total,
+    },
+  }
+}
+
 module.exports = {
   plansPage: expressify(plansPage),
   plansPageLightDesign: expressify(plansPageLightDesign),
@@ -896,6 +945,7 @@ module.exports = {
   cancelSubscription,
   canceledSubscription: expressify(canceledSubscription),
   cancelV1Subscription,
+  previewSubscription: expressify(previewSubscription),
   updateSubscription,
   cancelPendingSubscriptionChange,
   updateAccountEmailAddress,

+ 22 - 0
services/web/app/src/Features/Subscription/SubscriptionHandler.js

@@ -65,6 +65,26 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
   )
 }
 
+/**
+ * Preview the effect of changing the subscription plan
+ *
+ * @param {string} userId
+ * @param {string} planCode
+ * @return {Promise<RecurlySubscriptionChange>}
+ */
+async function previewSubscriptionChange(userId, planCode) {
+  const recurlyId = await getSubscriptionRecurlyId(userId)
+  if (recurlyId == null) {
+    throw new NoRecurlySubscriptionError('Subscription not found', { userId })
+  }
+
+  const subscription = await RecurlyClient.promises.getSubscription(recurlyId)
+  const changeRequest = subscription?.getRequestForPlanChange(planCode)
+  const change =
+    await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
+  return change
+}
+
 /**
  * @param user
  * @param planCode
@@ -361,6 +381,7 @@ async function getSubscriptionRecurlyId(userId) {
 module.exports = {
   validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
   createSubscription: callbackify(createSubscription),
+  previewSubscriptionChange: callbackify(previewSubscriptionChange),
   updateSubscription: callbackify(updateSubscription),
   cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange),
   cancelSubscription: callbackify(cancelSubscription),
@@ -374,6 +395,7 @@ module.exports = {
   promises: {
     validateNoSubscriptionInRecurly,
     createSubscription,
+    previewSubscriptionChange,
     updateSubscription,
     cancelPendingSubscriptionChange,
     cancelSubscription,

+ 6 - 0
services/web/app/src/Features/Subscription/SubscriptionRouter.mjs

@@ -121,6 +121,12 @@ export default {
     )
 
     // user changes their account state
+    webRouter.get(
+      '/user/subscription/preview',
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionController.previewSubscription
+    )
     webRouter.post(
       '/user/subscription/update',
       AuthenticationController.requireLogin(),

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -1485,6 +1485,7 @@
   "submit_title": "",
   "subscribe": "",
   "subscribe_to_find_the_symbols_you_need_faster": "",
+  "subscribe_to_plan": "",
   "subscription": "",
   "subscription_admins_cannot_be_deleted": "",
   "subscription_canceled": "",

+ 6 - 2
services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx

@@ -31,13 +31,17 @@ function PreviewSubscriptionChange() {
       <Row>
         <Col md={8} mdOffset={2}>
           <div className="card p-5">
-            {preview.change.type === 'add-on-purchase' && (
+            {preview.change.type === 'add-on-purchase' ? (
               <h1>
                 {t('add_add_on_to_your_plan', {
                   addOnName: preview.change.addOn.name,
                 })}
               </h1>
-            )}
+            ) : preview.change.type === 'premium-subscription' ? (
+              <h1>
+                {t('subscribe_to_plan', { planName: preview.change.plan.name })}
+              </h1>
+            ) : null}
 
             {payNowTask.isError && (
               <Notification

+ 1 - 0
services/web/locales/en.json

@@ -2062,6 +2062,7 @@
   "submit_title": "Submit",
   "subscribe": "Subscribe",
   "subscribe_to_find_the_symbols_you_need_faster": "Subscribe to find the symbols you need faster",
+  "subscribe_to_plan": "Subscribe to __planName__",
   "subscription": "Subscription",
   "subscription_admin_panel": "admin panel",
   "subscription_admins_cannot_be_deleted": "You cannot delete your account while on a subscription. Please cancel your subscription and try again. If you keep seeing this message please contact us.",

+ 3 - 2
services/web/package.json

@@ -234,7 +234,6 @@
     "@types/chai": "^4.3.0",
     "@types/dateformat": "^5.0.2",
     "@types/diff": "^5.0.9",
-    "uuid": "^9.0.1",
     "@types/dompurify": "^3.0.5",
     "@types/events": "^3.0.0",
     "@types/express": "^4.17.13",
@@ -258,8 +257,8 @@
     "@uppy/react": "^3.2.1",
     "@uppy/utils": "^5.7.0",
     "@uppy/xhr-upload": "^3.6.0",
-    "abort-controller": "^3.0.0",
     "5to6-codemod": "^1.8.0",
+    "abort-controller": "^3.0.0",
     "acorn": "^7.1.1",
     "acorn-walk": "^7.1.1",
     "algoliasearch": "^3.35.1",
@@ -323,6 +322,7 @@
     "mocha": "^10.2.0",
     "mocha-each": "^2.0.1",
     "mock-fs": "^5.1.2",
+    "nock": "^13.5.6",
     "nvd3": "^1.8.6",
     "overleaf-editor-core": "*",
     "pdfjs-dist": "4.6.82",
@@ -363,6 +363,7 @@
     "to-string-loader": "^1.2.0",
     "tty-browserify": "^0.0.1",
     "typescript": "^5.0.4",
+    "uuid": "^9.0.1",
     "w3c-keyname": "^2.2.8",
     "webpack": "^5.93.0",
     "webpack-assets-manifest": "^5.2.1",

+ 10 - 2
services/web/types/subscription/subscription-change-preview.ts

@@ -1,5 +1,5 @@
 export type SubscriptionChangePreview = {
-  change: SubscriptionChange
+  change: SubscriptionChangeDescription
   currency: string
   paymentMethod: string
   immediateCharge: number
@@ -27,7 +27,7 @@ type AddOn = {
   amount: number
 }
 
-type SubscriptionChange = AddOnPurchase
+export type SubscriptionChangeDescription = AddOnPurchase | PremiumSubscription
 
 type AddOnPurchase = {
   type: 'add-on-purchase'
@@ -36,3 +36,11 @@ type AddOnPurchase = {
     name: string
   }
 }
+
+type PremiumSubscription = {
+  type: 'premium-subscription'
+  plan: {
+    code: string
+    name: string
+  }
+}