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

Merge pull request #21556 from overleaf/em-subscription-change-interstitial

Add-on purchase preview page

GitOrigin-RevId: 660e39a94e6112af020ea783d6acf01a19432605
Eric Mc Sween 1 год назад
Родитель
Сommit
29be4f66d4
23 измененных файлов с 708 добавлено и 99 удалено
  1. 170 27
      services/web/app/src/Features/Subscription/RecurlyClient.js
  2. 87 9
      services/web/app/src/Features/Subscription/RecurlyEntities.js
  3. 91 21
      services/web/app/src/Features/Subscription/SubscriptionController.js
  4. 1 1
      services/web/app/src/Features/Subscription/SubscriptionFormatters.js
  5. 66 5
      services/web/app/src/Features/Subscription/SubscriptionHandler.js
  6. 1 1
      services/web/app/src/Features/Subscription/SubscriptionHelper.js
  7. 6 0
      services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
  8. 3 0
      services/web/app/src/Features/Subscription/types.ts
  9. 1 1
      services/web/app/src/util/currency.js
  10. 11 0
      services/web/app/views/subscriptions/preview-change.pug
  11. 5 0
      services/web/frontend/extracted-translations.json
  12. 1 1
      services/web/frontend/js/features/plans/utils/group-plan-pricing.js
  13. 120 0
      services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx
  14. 8 0
      services/web/frontend/js/pages/user/subscription/preview-change.tsx
  15. 5 20
      services/web/frontend/js/shared/utils/currency.ts
  16. 2 0
      services/web/frontend/js/utils/meta.ts
  17. 13 0
      services/web/frontend/stylesheets/app/subscription.less
  18. 5 0
      services/web/locales/en.json
  19. 10 6
      services/web/test/unit/src/Subscription/RecurlyClientTests.js
  20. 37 5
      services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js
  21. 9 2
      services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
  22. 18 0
      services/web/types/currency-code.ts
  23. 38 0
      services/web/types/subscription/subscription-change-preview.ts

+ 170 - 27
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -9,10 +9,15 @@ const UserGetter = require('../User/UserGetter')
 const {
 const {
   RecurlySubscription,
   RecurlySubscription,
   RecurlySubscriptionAddOn,
   RecurlySubscriptionAddOn,
+  RecurlySubscriptionChange,
+  PaypalPaymentMethod,
+  CreditCardPaymentMethod,
+  RecurlyAddOn,
 } = require('./RecurlyEntities')
 } = require('./RecurlyEntities')
 
 
 /**
 /**
  * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
  * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
+ * @import { PaymentMethod } from './types'
  */
  */
 
 
 const recurlySettings = Settings.apis.recurly
 const recurlySettings = Settings.apis.recurly
@@ -59,7 +64,7 @@ async function createAccountForUserId(userId) {
  */
  */
 async function getSubscription(subscriptionId) {
 async function getSubscription(subscriptionId) {
   const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
   const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
-  return makeSubscription(subscription)
+  return subscriptionFromApi(subscription)
 }
 }
 
 
 /**
 /**
@@ -68,36 +73,35 @@ async function getSubscription(subscriptionId) {
  * @param {RecurlySubscriptionChangeRequest} changeRequest
  * @param {RecurlySubscriptionChangeRequest} changeRequest
  */
  */
 async function applySubscriptionChangeRequest(changeRequest) {
 async function applySubscriptionChangeRequest(changeRequest) {
-  /** @type {recurly.SubscriptionChangeCreate} */
-  const body = {
-    timeframe: changeRequest.timeframe,
-  }
-  if (changeRequest.planCode != null) {
-    body.planCode = changeRequest.planCode
-  }
-  if (changeRequest.addOnUpdates != null) {
-    body.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
-      /** @type {recurly.SubscriptionAddOnUpdate} */
-      const update = { code: addOnUpdate.code }
-      if (addOnUpdate.quantity != null) {
-        update.quantity = addOnUpdate.quantity
-      }
-      if (addOnUpdate.unitPrice != null) {
-        update.unitAmount = addOnUpdate.unitPrice
-      }
-      return update
-    })
-  }
+  const body = subscriptionChangeRequestToApi(changeRequest)
   const change = await client.createSubscriptionChange(
   const change = await client.createSubscriptionChange(
-    `uuid-${changeRequest.subscriptionId}`,
+    `uuid-${changeRequest.subscription.id}`,
     body
     body
   )
   )
   logger.debug(
   logger.debug(
-    { subscriptionId: changeRequest.subscriptionId, changeId: change.id },
+    { subscriptionId: changeRequest.subscription.id, changeId: change.id },
     'created subscription change'
     'created subscription change'
   )
   )
 }
 }
 
 
+/**
+ * Preview a subscription change
+ *
+ * @param {RecurlySubscriptionChangeRequest} changeRequest
+ * @return {Promise<RecurlySubscriptionChange>}
+ */
+async function previewSubscriptionChange(changeRequest) {
+  const body = subscriptionChangeRequestToApi(changeRequest)
+  const subscriptionChange = await client.previewSubscriptionChange(
+    `uuid-${changeRequest.subscription.id}`,
+    body
+  )
+  return subscriptionChangeFromApi(
+    changeRequest.subscription,
+    subscriptionChange
+  )
+}
+
 async function removeSubscriptionChange(subscriptionId) {
 async function removeSubscriptionChange(subscriptionId) {
   const removed = await client.removeSubscriptionChange(subscriptionId)
   const removed = await client.removeSubscriptionChange(subscriptionId)
   logger.debug({ subscriptionId }, 'removed pending subscription change')
   logger.debug({ subscriptionId }, 'removed pending subscription change')
@@ -131,6 +135,32 @@ async function cancelSubscriptionByUuid(subscriptionUuid) {
   }
   }
 }
 }
 
 
+/**
+ * Get the payment method for the given user
+ *
+ * @param {string} userId
+ * @return {Promise<PaymentMethod>}
+ */
+async function getPaymentMethod(userId) {
+  const billingInfo = await client.getBillingInfo(`code-${userId}`)
+  return paymentMethodFromApi(billingInfo)
+}
+
+/**
+ * Get the configuration for a given add-on
+ *
+ * @param {string} planCode
+ * @param {string} addOnCode
+ * @return {Promise<RecurlyAddOn>}
+ */
+async function getAddOn(planCode, addOnCode) {
+  const addOn = await client.getPlanAddOn(
+    `code-${planCode}`,
+    `code-${addOnCode}`
+  )
+  return addOnFromApi(addOn)
+}
+
 function subscriptionIsCanceledOrExpired(subscription) {
 function subscriptionIsCanceledOrExpired(subscription) {
   const state = subscription?.recurlyStatus?.state
   const state = subscription?.recurlyStatus?.state
   return state === 'canceled' || state === 'expired'
   return state === 'canceled' || state === 'expired'
@@ -142,7 +172,7 @@ function subscriptionIsCanceledOrExpired(subscription) {
  * @param {recurly.Subscription} subscription
  * @param {recurly.Subscription} subscription
  * @return {RecurlySubscription}
  * @return {RecurlySubscription}
  */
  */
-function makeSubscription(subscription) {
+function subscriptionFromApi(subscription) {
   if (
   if (
     subscription.uuid == null ||
     subscription.uuid == null ||
     subscription.plan == null ||
     subscription.plan == null ||
@@ -153,7 +183,9 @@ function makeSubscription(subscription) {
     subscription.unitAmount == null ||
     subscription.unitAmount == null ||
     subscription.subtotal == null ||
     subscription.subtotal == null ||
     subscription.total == null ||
     subscription.total == null ||
-    subscription.currency == null
+    subscription.currency == null ||
+    subscription.currentPeriodStartedAt == null ||
+    subscription.currentPeriodEndsAt == null
   ) {
   ) {
     throw new OError('Invalid Recurly subscription', { subscription })
     throw new OError('Invalid Recurly subscription', { subscription })
   }
   }
@@ -163,12 +195,14 @@ function makeSubscription(subscription) {
     planCode: subscription.plan.code,
     planCode: subscription.plan.code,
     planName: subscription.plan.name,
     planName: subscription.plan.name,
     planPrice: subscription.unitAmount,
     planPrice: subscription.unitAmount,
-    addOns: (subscription.addOns ?? []).map(makeSubscriptionAddOn),
+    addOns: (subscription.addOns ?? []).map(subscriptionAddOnFromApi),
     subtotal: subscription.subtotal,
     subtotal: subscription.subtotal,
     taxRate: subscription.taxInfo?.rate ?? 0,
     taxRate: subscription.taxInfo?.rate ?? 0,
     taxAmount: subscription.tax ?? 0,
     taxAmount: subscription.tax ?? 0,
     total: subscription.total,
     total: subscription.total,
     currency: subscription.currency,
     currency: subscription.currency,
+    periodStart: subscription.currentPeriodStartedAt,
+    periodEnd: subscription.currentPeriodEndsAt,
   })
   })
 }
 }
 
 
@@ -178,7 +212,7 @@ function makeSubscription(subscription) {
  * @param {recurly.SubscriptionAddOn} addOn
  * @param {recurly.SubscriptionAddOn} addOn
  * @return {RecurlySubscriptionAddOn}
  * @return {RecurlySubscriptionAddOn}
  */
  */
-function makeSubscriptionAddOn(addOn) {
+function subscriptionAddOnFromApi(addOn) {
   if (
   if (
     addOn.addOn == null ||
     addOn.addOn == null ||
     addOn.addOn.code == null ||
     addOn.addOn.code == null ||
@@ -196,27 +230,136 @@ function makeSubscriptionAddOn(addOn) {
   })
   })
 }
 }
 
 
+/**
+ * Build a RecurlySubscriptionChange from Recurly API data
+ *
+ * @param {RecurlySubscription} subscription - the current subscription
+ * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
+ * @return {RecurlySubscriptionChange}
+ */
+function subscriptionChangeFromApi(subscription, subscriptionChange) {
+  if (
+    subscriptionChange.plan == null ||
+    subscriptionChange.plan.code == null ||
+    subscriptionChange.plan.name == null ||
+    subscriptionChange.unitAmount == null
+  ) {
+    throw new OError('Invalid Recurly subscription change', {
+      subscriptionChange,
+    })
+  }
+  const nextAddOns = (subscriptionChange.addOns ?? []).map(
+    subscriptionAddOnFromApi
+  )
+  return new RecurlySubscriptionChange({
+    subscription,
+    nextPlanCode: subscriptionChange.plan.code,
+    nextPlanName: subscriptionChange.plan.name,
+    nextPlanPrice: subscriptionChange.unitAmount,
+    nextAddOns,
+    immediateCharge:
+      subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0,
+  })
+}
+
+/**
+ * Returns a payment method from Recurly API data
+ *
+ * @param {recurly.BillingInfo} billingInfo
+ * @return {PaymentMethod}
+ */
+function paymentMethodFromApi(billingInfo) {
+  if (billingInfo.paymentMethod == null) {
+    throw new OError('Invalid Recurly billing info', { billingInfo })
+  }
+  const paymentMethod = billingInfo.paymentMethod
+
+  if (paymentMethod.billingAgreementId != null) {
+    return new PaypalPaymentMethod()
+  }
+
+  if (paymentMethod.cardType == null || paymentMethod.lastFour == null) {
+    throw new OError('Invalid Recurly billing info', { billingInfo })
+  }
+  return new CreditCardPaymentMethod({
+    cardType: paymentMethod.cardType,
+    lastFour: paymentMethod.lastFour,
+  })
+}
+
+/**
+ * Build a RecurlyAddOn from Recurly API data
+ *
+ * @param {recurly.AddOn} addOn
+ * @return {RecurlyAddOn}
+ */
+function addOnFromApi(addOn) {
+  if (addOn.code == null || addOn.name == null) {
+    throw new OError('Invalid Recurly add-on', { addOn })
+  }
+  return new RecurlyAddOn({
+    code: addOn.code,
+    name: addOn.name,
+  })
+}
+
+/**
+ * Build an API request from a RecurlySubscriptionChangeRequest
+ *
+ * @param {RecurlySubscriptionChangeRequest} changeRequest
+ * @return {recurly.SubscriptionChangeCreate}
+ */
+function subscriptionChangeRequestToApi(changeRequest) {
+  /** @type {recurly.SubscriptionChangeCreate} */
+  const requestBody = {
+    timeframe: changeRequest.timeframe,
+  }
+  if (changeRequest.planCode != null) {
+    requestBody.planCode = changeRequest.planCode
+  }
+  if (changeRequest.addOnUpdates != null) {
+    requestBody.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
+      /** @type {recurly.SubscriptionAddOnUpdate} */
+      const update = { code: addOnUpdate.code }
+      if (addOnUpdate.quantity != null) {
+        update.quantity = addOnUpdate.quantity
+      }
+      if (addOnUpdate.unitPrice != null) {
+        update.unitAmount = addOnUpdate.unitPrice
+      }
+      return update
+    })
+  }
+  return requestBody
+}
+
 module.exports = {
 module.exports = {
   errors: recurly.errors,
   errors: recurly.errors,
 
 
   getAccountForUserId: callbackify(getAccountForUserId),
   getAccountForUserId: callbackify(getAccountForUserId),
   createAccountForUserId: callbackify(createAccountForUserId),
   createAccountForUserId: callbackify(createAccountForUserId),
   getSubscription: callbackify(getSubscription),
   getSubscription: callbackify(getSubscription),
+  previewSubscriptionChange: callbackify(previewSubscriptionChange),
   applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
   applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
   removeSubscriptionChange: callbackify(removeSubscriptionChange),
   removeSubscriptionChange: callbackify(removeSubscriptionChange),
   removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
   removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
   reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
   reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
   cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
   cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
+  getPaymentMethod: callbackify(getPaymentMethod),
+  getAddOn: callbackify(getAddOn),
   subscriptionIsCanceledOrExpired,
   subscriptionIsCanceledOrExpired,
 
 
   promises: {
   promises: {
     getSubscription,
     getSubscription,
     getAccountForUserId,
     getAccountForUserId,
     createAccountForUserId,
     createAccountForUserId,
+    previewSubscriptionChange,
     applySubscriptionChangeRequest,
     applySubscriptionChangeRequest,
     removeSubscriptionChange,
     removeSubscriptionChange,
     removeSubscriptionChangeByUuid,
     removeSubscriptionChangeByUuid,
     reactivateSubscriptionByUuid,
     reactivateSubscriptionByUuid,
     cancelSubscriptionByUuid,
     cancelSubscriptionByUuid,
+    getPaymentMethod,
+    getAddOn,
   },
   },
 }
 }

+ 87 - 9
services/web/app/src/Features/Subscription/RecurlyEntities.js

@@ -19,6 +19,8 @@ class RecurlySubscription {
    * @param {number} [props.taxAmount]
    * @param {number} [props.taxAmount]
    * @param {string} props.currency
    * @param {string} props.currency
    * @param {number} props.total
    * @param {number} props.total
+   * @param {Date} props.periodStart
+   * @param {Date} props.periodEnd
    */
    */
   constructor(props) {
   constructor(props) {
     this.id = props.id
     this.id = props.id
@@ -32,6 +34,8 @@ class RecurlySubscription {
     this.taxAmount = props.taxAmount ?? 0
     this.taxAmount = props.taxAmount ?? 0
     this.currency = props.currency
     this.currency = props.currency
     this.total = props.total
     this.total = props.total
+    this.periodStart = props.periodStart
+    this.periodEnd = props.periodEnd
   }
   }
 
 
   hasAddOn(code) {
   hasAddOn(code) {
@@ -60,7 +64,7 @@ class RecurlySubscription {
     )
     )
     const timeframe = changeAtTermEnd ? 'term_end' : 'now'
     const timeframe = changeAtTermEnd ? 'term_end' : 'now'
     return new RecurlySubscriptionChangeRequest({
     return new RecurlySubscriptionChangeRequest({
-      subscriptionId: this.id,
+      subscription: this,
       timeframe,
       timeframe,
       planCode,
       planCode,
     })
     })
@@ -87,7 +91,7 @@ class RecurlySubscription {
     const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
     const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
     addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
     addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
     return new RecurlySubscriptionChangeRequest({
     return new RecurlySubscriptionChangeRequest({
-      subscriptionId: this.id,
+      subscription: this,
       timeframe: 'now',
       timeframe: 'now',
       addOnUpdates,
       addOnUpdates,
     })
     })
@@ -115,13 +119,16 @@ class RecurlySubscription {
       .filter(addOn => addOn.code !== code)
       .filter(addOn => addOn.code !== code)
       .map(addOn => addOn.toAddOnUpdate())
       .map(addOn => addOn.toAddOnUpdate())
     return new RecurlySubscriptionChangeRequest({
     return new RecurlySubscriptionChangeRequest({
-      subscriptionId: this.id,
+      subscription: this,
       timeframe: 'term_end',
       timeframe: 'term_end',
       addOnUpdates,
       addOnUpdates,
     })
     })
   }
   }
 }
 }
 
 
+/**
+ * An add-on attached to a subscription
+ */
 class RecurlySubscriptionAddOn {
 class RecurlySubscriptionAddOn {
   /**
   /**
    * @param {object} props
    * @param {object} props
@@ -135,10 +142,7 @@ class RecurlySubscriptionAddOn {
     this.name = props.name
     this.name = props.name
     this.quantity = props.quantity
     this.quantity = props.quantity
     this.unitPrice = props.unitPrice
     this.unitPrice = props.unitPrice
-  }
-
-  get preTaxTotal() {
-    return this.quantity * this.unitPrice
+    this.preTaxTotal = this.quantity * this.unitPrice
   }
   }
 
 
   /**
   /**
@@ -156,7 +160,7 @@ class RecurlySubscriptionAddOn {
 class RecurlySubscriptionChangeRequest {
 class RecurlySubscriptionChangeRequest {
   /**
   /**
    * @param {object} props
    * @param {object} props
-   * @param {string} props.subscriptionId
+   * @param {RecurlySubscription} props.subscription
    * @param {"now" | "term_end"} props.timeframe
    * @param {"now" | "term_end"} props.timeframe
    * @param {string} [props.planCode]
    * @param {string} [props.planCode]
    * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
    * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
@@ -165,7 +169,7 @@ class RecurlySubscriptionChangeRequest {
     if (props.planCode == null && props.addOnUpdates == null) {
     if (props.planCode == null && props.addOnUpdates == null) {
       throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
       throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
     }
     }
-    this.subscriptionId = props.subscriptionId
+    this.subscription = props.subscription
     this.timeframe = props.timeframe
     this.timeframe = props.timeframe
     this.planCode = props.planCode ?? null
     this.planCode = props.planCode ?? null
     this.addOnUpdates = props.addOnUpdates ?? null
     this.addOnUpdates = props.addOnUpdates ?? null
@@ -186,9 +190,83 @@ class RecurlySubscriptionAddOnUpdate {
   }
   }
 }
 }
 
 
+class RecurlySubscriptionChange {
+  /**
+   * @param {object} props
+   * @param {RecurlySubscription} props.subscription
+   * @param {string} props.nextPlanCode
+   * @param {string} props.nextPlanName
+   * @param {number} props.nextPlanPrice
+   * @param {RecurlySubscriptionAddOn[]} props.nextAddOns
+   * @param {number} [props.immediateCharge]
+   */
+  constructor(props) {
+    this.subscription = props.subscription
+    this.nextPlanCode = props.nextPlanCode
+    this.nextPlanName = props.nextPlanName
+    this.nextPlanPrice = props.nextPlanPrice
+    this.nextAddOns = props.nextAddOns
+    this.immediateCharge = props.immediateCharge ?? 0
+
+    this.subtotal = this.nextPlanPrice
+    for (const addOn of this.nextAddOns) {
+      this.subtotal += addOn.preTaxTotal
+    }
+
+    this.tax = Math.round(this.subtotal * 100 * this.subscription.taxRate) / 100
+
+    this.total = this.subtotal + this.tax
+  }
+
+  getAddOn(addOnCode) {
+    return this.nextAddOns.find(addOn => addOn.code === addOnCode)
+  }
+}
+
+class PaypalPaymentMethod {
+  toString() {
+    return 'Paypal'
+  }
+}
+
+class CreditCardPaymentMethod {
+  /**
+   * @param {object} props
+   * @param {string} props.cardType
+   * @param {string} props.lastFour
+   */
+  constructor(props) {
+    this.cardType = props.cardType
+    this.lastFour = props.lastFour
+  }
+
+  toString() {
+    return `${this.cardType} **** ${this.lastFour}`
+  }
+}
+
+/**
+ * An add-on configuration, independent of any subscription
+ */
+class RecurlyAddOn {
+  /**
+   * @param {object} props
+   * @param {string} props.code
+   * @param {string} props.name
+   */
+  constructor(props) {
+    this.code = props.code
+    this.name = props.name
+  }
+}
+
 module.exports = {
 module.exports = {
   RecurlySubscription,
   RecurlySubscription,
   RecurlySubscriptionAddOn,
   RecurlySubscriptionAddOn,
+  RecurlySubscriptionChange,
   RecurlySubscriptionChangeRequest,
   RecurlySubscriptionChangeRequest,
   RecurlySubscriptionAddOnUpdate,
   RecurlySubscriptionAddOnUpdate,
+  PaypalPaymentMethod,
+  CreditCardPaymentMethod,
+  RecurlyAddOn,
 }
 }

+ 91 - 21
services/web/app/src/Features/Subscription/SubscriptionController.js

@@ -1,3 +1,5 @@
+// @ts-check
+
 const SessionManager = require('../Authentication/SessionManager')
 const SessionManager = require('../Authentication/SessionManager')
 const SubscriptionHandler = require('./SubscriptionHandler')
 const SubscriptionHandler = require('./SubscriptionHandler')
 const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
 const SubscriptionViewModelBuilder = require('./SubscriptionViewModelBuilder')
@@ -25,8 +27,13 @@ const { formatCurrencyLocalized } = require('../../util/currency')
 const SubscriptionFormatters = require('./SubscriptionFormatters')
 const SubscriptionFormatters = require('./SubscriptionFormatters')
 const HttpErrorHandler = require('../Errors/HttpErrorHandler')
 const HttpErrorHandler = require('../Errors/HttpErrorHandler')
 const { URLSearchParams } = require('url')
 const { URLSearchParams } = require('url')
+const RecurlyClient = require('./RecurlyClient')
+
+/**
+ * @import { SubscriptionChangePreview } from '../../../../types/subscription/subscription-change-preview'
+ */
 
 
-const AI_ADDON_CODE = 'assistant'
+const AI_ADDON_CODES = ['assistant', 'assistant-annual']
 
 
 const groupPlanModalOptions = Settings.groupPlanModalOptions
 const groupPlanModalOptions = Settings.groupPlanModalOptions
 const validGroupPlanModalOptions = {
 const validGroupPlanModalOptions = {
@@ -222,11 +229,6 @@ function formatGroupPlansDataForDash() {
   }
   }
 }
 }
 
 
-/**
- * @param {import('express').Request} req
- * @param {import('express').Response} res
- * @returns {Promise<void>}
- */
 async function userSubscriptionPage(req, res) {
 async function userSubscriptionPage(req, res) {
   const user = SessionManager.getSessionUser(req.session)
   const user = SessionManager.getSessionUser(req.session)
 
 
@@ -402,11 +404,6 @@ async function interstitialPaymentPage(req, res) {
   }
   }
 }
 }
 
 
-/**
- * @param {import('express').Request} req
- * @param {import('express').Response} res
- * @returns {Promise<void>}
- */
 async function successfulSubscription(req, res) {
 async function successfulSubscription(req, res) {
   const user = SessionManager.getSessionUser(req.session)
   const user = SessionManager.getSessionUser(req.session)
   const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
   const localCcyAssignment = await SplitTestHandler.promises.getAssignment(
@@ -489,13 +486,81 @@ function cancelV1Subscription(req, res, next) {
   })
   })
 }
 }
 
 
+async function previewAddonPurchase(req, res) {
+  const userId = SessionManager.getLoggedInUserId(req.session)
+  const addOnCode = req.params.addOnCode
+
+  if (!AI_ADDON_CODES.includes(addOnCode)) {
+    return HttpErrorHandler.notFound(req, res, `Unknown add-on: ${addOnCode}`)
+  }
+
+  const paymentMethod = await RecurlyClient.promises.getPaymentMethod(userId)
+
+  let subscriptionChange
+  try {
+    subscriptionChange =
+      await SubscriptionHandler.promises.previewAddonPurchase(userId, addOnCode)
+  } catch (err) {
+    if (err instanceof DuplicateAddOnError) {
+      return HttpErrorHandler.badRequest(
+        req,
+        res,
+        `Subscription already has add-on "${addOnCode}"`
+      )
+    }
+    throw err
+  }
+
+  const subscription = subscriptionChange.subscription
+  const addOn = await RecurlyClient.promises.getAddOn(
+    subscription.planCode,
+    addOnCode
+  )
+
+  /** @type {SubscriptionChangePreview} */
+  const changePreview = {
+    change: {
+      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,
+    },
+  }
+
+  res.render('subscriptions/preview-change', { changePreview })
+}
+
 async function purchaseAddon(req, res, next) {
 async function purchaseAddon(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   const user = SessionManager.getSessionUser(req.session)
   const addOnCode = req.params.addOnCode
   const addOnCode = req.params.addOnCode
   // currently we only support having a quantity of 1
   // currently we only support having a quantity of 1
   const quantity = 1
   const quantity = 1
   // currently we only support one add-on, the Ai add-on
   // currently we only support one add-on, the Ai add-on
-  if (addOnCode !== AI_ADDON_CODE) {
+  if (!AI_ADDON_CODES.includes(addOnCode)) {
     return res.sendStatus(404)
     return res.sendStatus(404)
   }
   }
 
 
@@ -512,10 +577,12 @@ async function purchaseAddon(req, res, next) {
         { addon: addOnCode }
         { addon: addOnCode }
       )
       )
     } else {
     } else {
-      OError.tag(err, 'something went wrong purchasing add-ons', {
-        user_id: user._id,
-        addOnCode,
-      })
+      if (err instanceof Error) {
+        OError.tag(err, 'something went wrong purchasing add-ons', {
+          user_id: user._id,
+          addOnCode,
+        })
+      }
       return next(err)
       return next(err)
     }
     }
   }
   }
@@ -525,7 +592,7 @@ async function removeAddon(req, res, next) {
   const user = SessionManager.getSessionUser(req.session)
   const user = SessionManager.getSessionUser(req.session)
   const addOnCode = req.params.addOnCode
   const addOnCode = req.params.addOnCode
 
 
-  if (addOnCode !== AI_ADDON_CODE) {
+  if (!AI_ADDON_CODES.includes(addOnCode)) {
     return res.sendStatus(404)
     return res.sendStatus(404)
   }
   }
 
 
@@ -543,10 +610,12 @@ async function removeAddon(req, res, next) {
         { addon: addOnCode }
         { addon: addOnCode }
       )
       )
     } else {
     } else {
-      OError.tag(err, 'something went wrong removing add-ons', {
-        user_id: user._id,
-        addOnCode,
-      })
+      if (err instanceof Error) {
+        OError.tag(err, 'something went wrong removing add-ons', {
+          user_id: user._id,
+          addOnCode,
+        })
+      }
       return next(err)
       return next(err)
     }
     }
   }
   }
@@ -839,6 +908,7 @@ module.exports = {
   refreshUserFeatures: expressify(refreshUserFeatures),
   refreshUserFeatures: expressify(refreshUserFeatures),
   redirectToHostedPage: expressify(redirectToHostedPage),
   redirectToHostedPage: expressify(redirectToHostedPage),
   plansBanners: _plansBanners,
   plansBanners: _plansBanners,
+  previewAddonPurchase: expressify(previewAddonPurchase),
   purchaseAddon,
   purchaseAddon,
   removeAddon,
   removeAddon,
   promises: {
   promises: {

+ 1 - 1
services/web/app/src/Features/Subscription/SubscriptionFormatters.js

@@ -2,7 +2,7 @@ const dateformat = require('dateformat')
 const { formatCurrencyLocalized } = require('../../util/currency')
 const { formatCurrencyLocalized } = require('../../util/currency')
 
 
 /**
 /**
- * @import { CurrencyCode } from '@/shared/utils/currency'
+ * @import { CurrencyCode } from '../../../../types/currency-code'
  */
  */
 
 
 const currencySymbols = {
 const currencySymbols = {

+ 66 - 5
services/web/app/src/Features/Subscription/SubscriptionHandler.js

@@ -5,6 +5,7 @@ const RecurlyClient = require('./RecurlyClient')
 const { User } = require('../../models/User')
 const { User } = require('../../models/User')
 const logger = require('@overleaf/logger')
 const logger = require('@overleaf/logger')
 const SubscriptionUpdater = require('./SubscriptionUpdater')
 const SubscriptionUpdater = require('./SubscriptionUpdater')
+const SubscriptionLocator = require('./SubscriptionLocator')
 const LimitationsManager = require('./LimitationsManager')
 const LimitationsManager = require('./LimitationsManager')
 const EmailHandler = require('../Email/EmailHandler')
 const EmailHandler = require('../Email/EmailHandler')
 const { callbackify } = require('@overleaf/promise-utils')
 const { callbackify } = require('@overleaf/promise-utils')
@@ -12,7 +13,8 @@ const UserUpdater = require('../User/UserUpdater')
 const { NoRecurlySubscriptionError } = require('./Errors')
 const { NoRecurlySubscriptionError } = require('./Errors')
 
 
 /**
 /**
- * @import { RecurlySubscription } from './RecurlyEntities'
+ * @import recurly from 'recurly'
+ * @import { RecurlySubscription, RecurlySubscriptionChange } from './RecurlyEntities'
  */
  */
 
 
 async function validateNoSubscriptionInRecurly(userId) {
 async function validateNoSubscriptionInRecurly(userId) {
@@ -62,6 +64,11 @@ async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
   )
   )
 }
 }
 
 
+/**
+ * @param user
+ * @param planCode
+ * @param couponCode
+ */
 async function updateSubscription(user, planCode, couponCode) {
 async function updateSubscription(user, planCode, couponCode) {
   let hasSubscription = false
   let hasSubscription = false
   let subscription
   let subscription
@@ -105,6 +112,9 @@ async function updateSubscription(user, planCode, couponCode) {
   await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
   await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
 }
 }
 
 
+/**
+ * @param user
+ */
 async function cancelPendingSubscriptionChange(user) {
 async function cancelPendingSubscriptionChange(user) {
   const { hasSubscription, subscription } =
   const { hasSubscription, subscription } =
     await LimitationsManager.promises.userHasV2Subscription(user)
     await LimitationsManager.promises.userHasV2Subscription(user)
@@ -116,6 +126,9 @@ async function cancelPendingSubscriptionChange(user) {
   }
   }
 }
 }
 
 
+/**
+ * @param user
+ */
 async function cancelSubscription(user) {
 async function cancelSubscription(user) {
   try {
   try {
     const { hasSubscription, subscription } =
     const { hasSubscription, subscription } =
@@ -144,6 +157,9 @@ async function cancelSubscription(user) {
   }
   }
 }
 }
 
 
+/**
+ * @param user
+ */
 async function reactivateSubscription(user) {
 async function reactivateSubscription(user) {
   try {
   try {
     const { hasSubscription, subscription } =
     const { hasSubscription, subscription } =
@@ -174,6 +190,10 @@ async function reactivateSubscription(user) {
   }
   }
 }
 }
 
 
+/**
+ * @param recurlySubscription
+ * @param requesterData
+ */
 async function syncSubscription(recurlySubscription, requesterData) {
 async function syncSubscription(recurlySubscription, requesterData) {
   const storedSubscription = await RecurlyWrapper.promises.getSubscription(
   const storedSubscription = await RecurlyWrapper.promises.getSubscription(
     recurlySubscription.uuid,
     recurlySubscription.uuid,
@@ -195,10 +215,14 @@ async function syncSubscription(recurlySubscription, requesterData) {
   )
   )
 }
 }
 
 
-// attempt to collect past due invoice for customer. Only do that when a) the
-// customer is using Paypal and b) there is only one past due invoice.
-// This is used because Recurly doesn't always attempt collection of paast due
-// invoices after Paypal billing info were updated.
+/**
+ * attempt to collect past due invoice for customer. Only do that when a) the
+ * customer is using Paypal and b) there is only one past due invoice.
+ * This is used because Recurly doesn't always attempt collection of paast due
+ * invoices after Paypal billing info were updated.
+ *
+ * @param recurlyAccountCode
+ */
 async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
 async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
   const billingInfo =
   const billingInfo =
     await RecurlyWrapper.promises.getBillingInfo(recurlyAccountCode)
     await RecurlyWrapper.promises.getBillingInfo(recurlyAccountCode)
@@ -259,6 +283,26 @@ async function _getSubscription(user) {
   return currentSub
   return currentSub
 }
 }
 
 
+/**
+ * Preview the effect of purchasing an add-on
+ *
+ * @param {string} userId
+ * @param {string} addOnCode
+ * @return {Promise<RecurlySubscriptionChange>}
+ */
+async function previewAddonPurchase(userId, addOnCode) {
+  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.getRequestForAddOnPurchase(addOnCode)
+  const change =
+    await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
+  return change
+}
+
 async function purchaseAddon(user, addOnCode, quantity) {
 async function purchaseAddon(user, addOnCode, quantity) {
   const subscription = await _getSubscription(user)
   const subscription = await _getSubscription(user)
   const changeRequest = subscription.getRequestForAddOnPurchase(
   const changeRequest = subscription.getRequestForAddOnPurchase(
@@ -276,6 +320,21 @@ async function removeAddon(user, addOnCode) {
   await syncSubscription({ uuid: subscription.id }, user._id)
   await syncSubscription({ uuid: subscription.id }, user._id)
 }
 }
 
 
+/**
+ * Returns the Recurly UUID for the given user
+ *
+ * @param {string} userId
+ * @return {Promise<string | null>} the Recurly UUID
+ */
+async function getSubscriptionRecurlyId(userId) {
+  const subscription =
+    await SubscriptionLocator.promises.getUsersSubscription(userId)
+  if (subscription == null) {
+    return null
+  }
+  return subscription.recurlySubscription_id ?? null
+}
+
 module.exports = {
 module.exports = {
   validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
   validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
   createSubscription: callbackify(createSubscription),
   createSubscription: callbackify(createSubscription),
@@ -286,6 +345,7 @@ module.exports = {
   syncSubscription: callbackify(syncSubscription),
   syncSubscription: callbackify(syncSubscription),
   attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
   attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
   extendTrial: callbackify(extendTrial),
   extendTrial: callbackify(extendTrial),
+  previewAddonPurchase: callbackify(previewAddonPurchase),
   purchaseAddon: callbackify(purchaseAddon),
   purchaseAddon: callbackify(purchaseAddon),
   removeAddon: callbackify(removeAddon),
   removeAddon: callbackify(removeAddon),
   promises: {
   promises: {
@@ -298,6 +358,7 @@ module.exports = {
     syncSubscription,
     syncSubscription,
     attemptPaypalInvoiceCollection,
     attemptPaypalInvoiceCollection,
     extendTrial,
     extendTrial,
+    previewAddonPurchase,
     purchaseAddon,
     purchaseAddon,
     removeAddon,
     removeAddon,
   },
   },

+ 1 - 1
services/web/app/src/Features/Subscription/SubscriptionHelper.js

@@ -9,7 +9,7 @@ function shouldPlanChangeAtTermEnd(oldPlan, newPlan) {
 }
 }
 
 
 /**
 /**
- * @import { CurrencyCode } from '../../../../frontend/js/shared/utils/currency'
+ * @import { CurrencyCode } from '../../../../types/currency-code'
  */
  */
 
 
 /**
 /**

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

@@ -120,6 +120,12 @@ export default {
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
       RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
       SubscriptionController.updateSubscription
       SubscriptionController.updateSubscription
     )
     )
+    webRouter.get(
+      '/user/subscription/addon/:addOnCode/add',
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+      SubscriptionController.previewAddonPurchase
+    )
     webRouter.post(
     webRouter.post(
       '/user/subscription/addon/:addOnCode/add',
       '/user/subscription/addon/:addOnCode/add',
       AuthenticationController.requireLogin(),
       AuthenticationController.requireLogin(),

+ 3 - 0
services/web/app/src/Features/Subscription/types.ts

@@ -0,0 +1,3 @@
+import { PaypalPaymentMethod, CreditCardPaymentMethod } from './RecurlyEntities'
+
+export type PaymentMethod = PaypalPaymentMethod | CreditCardPaymentMethod

+ 1 - 1
services/web/app/src/util/currency.js

@@ -3,7 +3,7 @@
  */
  */
 
 
 /**
 /**
- * @import { CurrencyCode } from '@/shared/utils/currency'
+ * @import { CurrencyCode } from '../../../types/currency-code'
  */
  */
 
 
 /**
 /**

+ 11 - 0
services/web/app/views/subscriptions/preview-change.pug

@@ -0,0 +1,11 @@
+extends ../layout-marketing
+
+block entrypointVar
+	- entrypoint = 'pages/user/subscription/preview-change'
+
+block append meta
+	meta(name="ol-subscriptionChangePreview" data-type="json" content=changePreview)
+
+block content
+	main.content.content-alt#main-content
+		#subscription-preview-change

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

@@ -48,6 +48,7 @@
   "active": "",
   "active": "",
   "add": "",
   "add": "",
   "add_a_recovery_email_address": "",
   "add_a_recovery_email_address": "",
+  "add_add_on_to_your_plan": "",
   "add_additional_certificate": "",
   "add_additional_certificate": "",
   "add_affiliation": "",
   "add_affiliation": "",
   "add_another_address_line": "",
   "add_another_address_line": "",
@@ -522,6 +523,7 @@
   "full_doc_history": "",
   "full_doc_history": "",
   "full_project_search": "",
   "full_project_search": "",
   "full_width": "",
   "full_width": "",
+  "future_payments": "",
   "generate_token": "",
   "generate_token": "",
   "generic_if_problem_continues_contact_us": "",
   "generic_if_problem_continues_contact_us": "",
   "generic_linked_file_compile_error": "",
   "generic_linked_file_compile_error": "",
@@ -1027,6 +1029,7 @@
   "paste_options": "",
   "paste_options": "",
   "paste_with_formatting": "",
   "paste_with_formatting": "",
   "paste_without_formatting": "",
   "paste_without_formatting": "",
+  "pay_now": "",
   "payment_provider_unreachable_error": "",
   "payment_provider_unreachable_error": "",
   "payment_summary": "",
   "payment_summary": "",
   "pdf_compile_in_progress_error": "",
   "pdf_compile_in_progress_error": "",
@@ -1522,7 +1525,9 @@
   "the_following_files_and_folders_already_exist_in_this_project": "",
   "the_following_files_and_folders_already_exist_in_this_project": "",
   "the_following_folder_already_exists_in_this_project": "",
   "the_following_folder_already_exists_in_this_project": "",
   "the_following_folder_already_exists_in_this_project_plural": "",
   "the_following_folder_already_exists_in_this_project_plural": "",
+  "the_next_payment_will_be_collected_on": "",
   "the_original_text_has_changed": "",
   "the_original_text_has_changed": "",
+  "the_payment_method_used_is": "",
   "the_target_folder_could_not_be_found": "",
   "the_target_folder_could_not_be_found": "",
   "the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
   "the_width_you_choose_here_is_based_on_the_width_of_the_text_in_your_document": "",
   "their_projects_will_be_transferred_to_another_user": "",
   "their_projects_will_be_transferred_to_another_user": "",

+ 1 - 1
services/web/frontend/js/features/plans/utils/group-plan-pricing.js

@@ -1,7 +1,7 @@
 import getMeta from '../../../utils/meta'
 import getMeta from '../../../utils/meta'
 
 
 /**
 /**
- * @import { CurrencyCode } from '@/shared/utils/currency'
+ * @import { CurrencyCode } from '../../../../../types/currency-code'
  */
  */
 
 
 // plan: 'collaborator' or 'professional'
 // plan: 'collaborator' or 'professional'

+ 120 - 0
services/web/frontend/js/features/subscription/components/preview-subscription-change/root.tsx

@@ -0,0 +1,120 @@
+import { Grid, Row, Col, Button } from 'react-bootstrap'
+import moment from 'moment'
+import { useTranslation, Trans } from 'react-i18next'
+import getMeta from '@/utils/meta'
+import { formatCurrencyLocalized } from '@/shared/utils/currency'
+
+function PreviewSubscriptionChange() {
+  const { t } = useTranslation()
+  const preview = getMeta('ol-subscriptionChangePreview')
+  return (
+    <Grid>
+      <Row>
+        <Col md={8} mdOffset={2}>
+          <div className="card p-5">
+            {preview.change.type === 'add-on-purchase' && (
+              <h1>
+                {t('add_add_on_to_your_plan', {
+                  addOnName: preview.change.addOn.name,
+                })}
+              </h1>
+            )}
+            <div className="payment-summary-card mt-5">
+              <h3>{t('payment_summary')}</h3>
+              <Row>
+                <Col xs={9}>
+                  <strong>{t('due_today')}:</strong>
+                </Col>
+                <Col xs={3} className="text-right">
+                  <strong>
+                    {formatCurrencyLocalized(
+                      preview.immediateCharge,
+                      preview.currency
+                    )}
+                  </strong>
+                </Col>
+              </Row>
+
+              <hr />
+
+              <div>
+                <strong>{t('future_payments')}:</strong>
+              </div>
+
+              <Row className="mt-1">
+                <Col xs={9}>{preview.nextInvoice.plan.name}</Col>
+                <Col xs={3} className="text-right">
+                  {formatCurrencyLocalized(
+                    preview.nextInvoice.plan.amount,
+                    preview.currency
+                  )}
+                </Col>
+              </Row>
+
+              {preview.nextInvoice.addOns.map(addOn => (
+                <Row className="mt-1" key={addOn.code}>
+                  <Col xs={9}>
+                    {addOn.name}
+                    {addOn.quantity > 1 ? ` ×${addOn.quantity}` : ''}
+                  </Col>
+                  <Col xs={3} className="text-right">
+                    {formatCurrencyLocalized(addOn.amount, preview.currency)}
+                  </Col>
+                </Row>
+              ))}
+
+              {preview.nextInvoice.tax.rate > 0 && (
+                <Row className="mt-1">
+                  <Col xs={9}>
+                    {t('vat')} {preview.nextInvoice.tax.rate * 100}%
+                  </Col>
+                  <Col xs={3} className="text-right">
+                    {formatCurrencyLocalized(
+                      preview.nextInvoice.tax.amount,
+                      preview.currency
+                    )}
+                  </Col>
+                </Row>
+              )}
+
+              <Row className="mt-1">
+                <Col xs={9}>{t('total_per_month')}</Col>
+                <Col xs={3} className="text-right">
+                  {formatCurrencyLocalized(
+                    preview.nextInvoice.total,
+                    preview.currency
+                  )}
+                </Col>
+              </Row>
+            </div>
+
+            <div className="mt-5">
+              <Trans
+                i18nKey="the_next_payment_will_be_collected_on"
+                values={{ date: moment(preview.nextInvoice.date).format('LL') }}
+                components={{ strong: <strong /> }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
+              />{' '}
+              <Trans
+                i18nKey="the_payment_method_used_is"
+                values={{ paymentMethod: preview.paymentMethod }}
+                components={{ strong: <strong /> }}
+                shouldUnescape
+                tOptions={{ interpolation: { escapeValue: true } }}
+              />
+            </div>
+
+            <div className="mt-5">
+              <Button bsStyle="primary" bsSize="large">
+                {t('pay_now')}
+              </Button>
+            </div>
+          </div>
+        </Col>
+      </Row>
+    </Grid>
+  )
+}
+
+export default PreviewSubscriptionChange

+ 8 - 0
services/web/frontend/js/pages/user/subscription/preview-change.tsx

@@ -0,0 +1,8 @@
+import '@/marketing'
+import ReactDOM from 'react-dom'
+import PreviewSubscriptionChange from '@/features/subscription/components/preview-subscription-change/root'
+
+const element = document.getElementById('subscription-preview-change')
+if (element) {
+  ReactDOM.render(<PreviewSubscriptionChange />, element)
+}

+ 5 - 20
services/web/frontend/js/shared/utils/currency.ts

@@ -1,26 +1,11 @@
-export type CurrencyCode =
-  | 'AUD'
-  | 'BRL'
-  | 'CAD'
-  | 'CHF'
-  | 'CLP'
-  | 'COP'
-  | 'DKK'
-  | 'EUR'
-  | 'GBP'
-  | 'INR'
-  | 'MXN'
-  | 'NOK'
-  | 'NZD'
-  | 'PEN'
-  | 'SEK'
-  | 'SGD'
-  | 'USD'
+import getMeta from '@/utils/meta'
+
+const DEFAULT_LOCALE = getMeta('ol-i18n')?.currentLangCode ?? 'en'
 
 
 export function formatCurrencyLocalized(
 export function formatCurrencyLocalized(
   amount: number,
   amount: number,
-  currency: CurrencyCode,
-  locale: string,
+  currency: string,
+  locale: string = DEFAULT_LOCALE,
   stripIfInteger = false
   stripIfInteger = false
 ): string {
 ): string {
   const options: Intl.NumberFormatOptions = { style: 'currency', currency }
   const options: Intl.NumberFormatOptions = { style: 'currency', currency }

+ 2 - 0
services/web/frontend/js/utils/meta.ts

@@ -47,6 +47,7 @@ import { PasswordStrengthOptions } from '../../../types/password-strength-option
 import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
 import { Subscription as ProjectDashboardSubscription } from '../../../types/project/dashboard/subscription'
 import { ThirdPartyIds } from '../../../types/third-party-ids'
 import { ThirdPartyIds } from '../../../types/third-party-ids'
 import { Publisher } from '../../../types/subscription/dashboard/publisher'
 import { Publisher } from '../../../types/subscription/dashboard/publisher'
+import { SubscriptionChangePreview } from '../../../types/subscription/subscription-change-preview'
 import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
 import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
 import { FatFooterMetadata } from '@/features/ui/components/types/fat-footer-metadata'
 import { FatFooterMetadata } from '@/features/ui/components/types/fat-footer-metadata'
 export interface Meta {
 export interface Meta {
@@ -191,6 +192,7 @@ export interface Meta {
   'ol-ssoDisabled': boolean
   'ol-ssoDisabled': boolean
   'ol-ssoErrorMessage': string
   'ol-ssoErrorMessage': string
   'ol-subscription': any // TODO: mixed types, split into two fields
   'ol-subscription': any // TODO: mixed types, split into two fields
+  'ol-subscriptionChangePreview': SubscriptionChangePreview
   'ol-subscriptionId': string
   'ol-subscriptionId': string
   'ol-suggestedLanguage': SuggestedLanguage | undefined
   'ol-suggestedLanguage': SuggestedLanguage | undefined
   'ol-survey': Survey | undefined
   'ol-survey': Survey | undefined

+ 13 - 0
services/web/frontend/stylesheets/app/subscription.less

@@ -286,3 +286,16 @@ a.row-link {
   height: 20px;
   height: 20px;
   width: 20px;
   width: 20px;
 }
 }
+
+.payment-summary-card {
+  &:extend(.card-gray);
+  border-radius: @border-radius-large-new;
+
+  h3 {
+    margin-top: 0;
+    margin-bottom: @spacing-08;
+    font-family: @font-family-sans-serif;
+    font-weight: 600;
+    color: @content-secondary;
+  }
+}

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

@@ -59,6 +59,7 @@
   "active": "Active",
   "active": "Active",
   "add": "Add",
   "add": "Add",
   "add_a_recovery_email_address": "Add a recovery email address",
   "add_a_recovery_email_address": "Add a recovery email address",
+  "add_add_on_to_your_plan": "Add __addOnName__ to your plan",
   "add_additional_certificate": "Add another certificate",
   "add_additional_certificate": "Add another certificate",
   "add_affiliation": "Add Affiliation",
   "add_affiliation": "Add Affiliation",
   "add_another_address_line": "Add another address line",
   "add_another_address_line": "Add another address line",
@@ -748,6 +749,7 @@
   "full_document_history": "Full document <0>history</0>",
   "full_document_history": "Full document <0>history</0>",
   "full_project_search": "Full Project Search",
   "full_project_search": "Full Project Search",
   "full_width": "Full width",
   "full_width": "Full width",
+  "future_payments": "Future payments",
   "gallery": "Gallery",
   "gallery": "Gallery",
   "gallery_back_to_all": "Back to all __itemPlural__",
   "gallery_back_to_all": "Back to all __itemPlural__",
   "gallery_find_more": "Find More __itemPlural__",
   "gallery_find_more": "Find More __itemPlural__",
@@ -1483,6 +1485,7 @@
   "paste_options": "Paste options",
   "paste_options": "Paste options",
   "paste_with_formatting": "Paste with formatting",
   "paste_with_formatting": "Paste with formatting",
   "paste_without_formatting": "Paste without formatting",
   "paste_without_formatting": "Paste without formatting",
+  "pay_now": "Pay now",
   "payment_method_accepted": "__paymentMethod__ accepted",
   "payment_method_accepted": "__paymentMethod__ accepted",
   "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.",
   "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.",
   "payment_summary": "Payment summary",
   "payment_summary": "Payment summary",
@@ -2124,7 +2127,9 @@
   "the_following_files_and_folders_already_exist_in_this_project": "The following files and folders already exist in this project:",
   "the_following_files_and_folders_already_exist_in_this_project": "The following files and folders already exist in this project:",
   "the_following_folder_already_exists_in_this_project": "The following folder already exists in this project:",
   "the_following_folder_already_exists_in_this_project": "The following folder already exists in this project:",
   "the_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:",
   "the_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:",
+  "the_next_payment_will_be_collected_on": "The next payment will be collected on <strong>__date__</strong>.",
   "the_original_text_has_changed": "The original text has changed, so this suggestion can’t be applied",
   "the_original_text_has_changed": "The original text has changed, so this suggestion can’t be applied",
+  "the_payment_method_used_is": "The payment method used is <strong>__paymentMethod__</strong>.",
   "the_project_that_contains_this_file_is_not_shared_with_you": "The project that contains this file is not shared with you",
   "the_project_that_contains_this_file_is_not_shared_with_you": "The project that contains this file is not shared with you",
   "the_requested_conversion_job_was_not_found": "The link to open this content on Overleaf specified a conversion job that could not be found. It’s possible that the job has expired and needs to be run again. If this keeps happening for links on a particular site, please report this to them.",
   "the_requested_conversion_job_was_not_found": "The link to open this content on Overleaf specified a conversion job that could not be found. It’s possible that the job has expired and needs to be run again. If this keeps happening for links on a particular site, please report this to them.",
   "the_requested_publisher_was_not_found": "The link to open this content on Overleaf specified a publisher that could not be found. If this keeps happening for links on a particular site, please report this to them.",
   "the_requested_publisher_was_not_found": "The link to open this content on Overleaf specified a publisher that could not be found. If this keeps happening for links on a particular site, please report this to them.",

+ 10 - 6
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -32,6 +32,7 @@ describe('RecurlyClient', function () {
       name: 'My Add-On',
       name: 'My Add-On',
       quantity: 1,
       quantity: 1,
       unitPrice: 2,
       unitPrice: 2,
+      preTaxTotal: 2,
     }
     }
 
 
     this.subscription = {
     this.subscription = {
@@ -46,6 +47,8 @@ describe('RecurlyClient', function () {
       taxRate: 0.1,
       taxRate: 0.1,
       taxAmount: 1.5,
       taxAmount: 1.5,
       total: 16.5,
       total: 16.5,
+      periodStart: new Date(),
+      periodEnd: new Date(),
     }
     }
 
 
     this.recurlySubscription = {
     this.recurlySubscription = {
@@ -73,6 +76,8 @@ describe('RecurlyClient', function () {
       tax: this.subscription.taxAmount,
       tax: this.subscription.taxAmount,
       total: this.subscription.total,
       total: this.subscription.total,
       currency: this.subscription.currency,
       currency: this.subscription.currency,
+      currentPeriodStartedAt: this.subscription.periodStart,
+      currentPeriodEndsAt: this.subscription.periodEnd,
     }
     }
 
 
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()
@@ -203,7 +208,7 @@ describe('RecurlyClient', function () {
     it('handles plan changes', async function () {
     it('handles plan changes', async function () {
       await this.RecurlyClient.promises.applySubscriptionChangeRequest(
       await this.RecurlyClient.promises.applySubscriptionChangeRequest(
         new RecurlySubscriptionChangeRequest({
         new RecurlySubscriptionChangeRequest({
-          subscriptionId: this.subscription.id,
+          subscription: this.subscription,
           timeframe: 'now',
           timeframe: 'now',
           planCode: 'new-plan',
           planCode: 'new-plan',
         })
         })
@@ -217,7 +222,7 @@ describe('RecurlyClient', function () {
     it('handles add-on changes', async function () {
     it('handles add-on changes', async function () {
       await this.RecurlyClient.promises.applySubscriptionChangeRequest(
       await this.RecurlyClient.promises.applySubscriptionChangeRequest(
         new RecurlySubscriptionChangeRequest({
         new RecurlySubscriptionChangeRequest({
-          subscriptionId: this.subscription.id,
+          subscription: this.subscription,
           timeframe: 'now',
           timeframe: 'now',
           addOnUpdates: [
           addOnUpdates: [
             new RecurlySubscriptionAddOnUpdate({
             new RecurlySubscriptionAddOnUpdate({
@@ -240,10 +245,9 @@ describe('RecurlyClient', function () {
     it('should throw any API errors', async function () {
     it('should throw any API errors', async function () {
       this.client.createSubscriptionChange = sinon.stub().throws()
       this.client.createSubscriptionChange = sinon.stub().throws()
       await expect(
       await expect(
-        this.RecurlyClient.promises.applySubscriptionChangeRequest(
-          this.subscription.id,
-          {}
-        )
+        this.RecurlyClient.promises.applySubscriptionChangeRequest({
+          subscription: this.subscription,
+        })
       ).to.eventually.be.rejectedWith(Error)
       ).to.eventually.be.rejectedWith(Error)
     })
     })
   })
   })

+ 37 - 5
services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js

@@ -5,6 +5,8 @@ const { expect } = require('chai')
 const Errors = require('../../../../app/src/Features/Subscription/Errors')
 const Errors = require('../../../../app/src/Features/Subscription/Errors')
 const {
 const {
   RecurlySubscriptionChangeRequest,
   RecurlySubscriptionChangeRequest,
+  RecurlySubscriptionChange,
+  RecurlySubscription,
 } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
 } = require('../../../../app/src/Features/Subscription/RecurlyEntities')
 
 
 const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
 const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
@@ -71,7 +73,7 @@ describe('RecurlyEntities', function () {
             this.subscription.getRequestForPlanChange('premium-plan')
             this.subscription.getRequestForPlanChange('premium-plan')
           expect(changeRequest).to.deep.equal(
           expect(changeRequest).to.deep.equal(
             new RecurlySubscriptionChangeRequest({
             new RecurlySubscriptionChangeRequest({
-              subscriptionId: this.subscription.id,
+              subscription: this.subscription,
               timeframe: 'now',
               timeframe: 'now',
               planCode: 'premium-plan',
               planCode: 'premium-plan',
             })
             })
@@ -84,7 +86,7 @@ describe('RecurlyEntities', function () {
             this.subscription.getRequestForPlanChange('cheap-plan')
             this.subscription.getRequestForPlanChange('cheap-plan')
           expect(changeRequest).to.deep.equal(
           expect(changeRequest).to.deep.equal(
             new RecurlySubscriptionChangeRequest({
             new RecurlySubscriptionChangeRequest({
-              subscriptionId: this.subscription.id,
+              subscription: this.subscription,
               timeframe: 'term_end',
               timeframe: 'term_end',
               planCode: 'cheap-plan',
               planCode: 'cheap-plan',
             })
             })
@@ -102,7 +104,7 @@ describe('RecurlyEntities', function () {
             this.subscription.getRequestForAddOnPurchase('another-add-on')
             this.subscription.getRequestForAddOnPurchase('another-add-on')
           expect(changeRequest).to.deep.equal(
           expect(changeRequest).to.deep.equal(
             new RecurlySubscriptionChangeRequest({
             new RecurlySubscriptionChangeRequest({
-              subscriptionId: this.subscription.id,
+              subscription: this.subscription,
               timeframe: 'now',
               timeframe: 'now',
               addOnUpdates: [
               addOnUpdates: [
                 new RecurlySubscriptionAddOnUpdate({
                 new RecurlySubscriptionAddOnUpdate({
@@ -133,7 +135,7 @@ describe('RecurlyEntities', function () {
           )
           )
           expect(changeRequest).to.deep.equal(
           expect(changeRequest).to.deep.equal(
             new RecurlySubscriptionChangeRequest({
             new RecurlySubscriptionChangeRequest({
-              subscriptionId: this.subscription.id,
+              subscription: this.subscription,
               timeframe: 'term_end',
               timeframe: 'term_end',
               addOnUpdates: [],
               addOnUpdates: [],
             })
             })
@@ -180,7 +182,7 @@ describe('RecurlyEntities', function () {
               this.subscription.getRequestForAddOnPurchase('some-add-on')
               this.subscription.getRequestForAddOnPurchase('some-add-on')
             expect(changeRequest).to.deep.equal(
             expect(changeRequest).to.deep.equal(
               new RecurlySubscriptionChangeRequest({
               new RecurlySubscriptionChangeRequest({
-                subscriptionId: this.subscription.id,
+                subscription: this.subscription,
                 timeframe: 'now',
                 timeframe: 'now',
                 addOnUpdates: [
                 addOnUpdates: [
                   new RecurlySubscriptionAddOnUpdate({
                   new RecurlySubscriptionAddOnUpdate({
@@ -203,4 +205,34 @@ describe('RecurlyEntities', function () {
       })
       })
     })
     })
   })
   })
+
+  describe('RecurlySubscriptionChange', function () {
+    describe('constructor', function () {
+      it('rounds the amounts when calculating the taxes', function () {
+        const subscription = new RecurlySubscription({
+          id: 'subscription-id',
+          userId: 'user-id',
+          planCode: 'premium-plan',
+          planName: 'Premium plan',
+          planPrice: 10,
+          subtotal: 10,
+          taxRate: 0.15,
+          taxAmount: 1.5,
+          currency: 'USD',
+          total: 11.5,
+          periodStart: new Date(),
+          periodEnd: new Date(),
+        })
+        const change = new RecurlySubscriptionChange({
+          subscription,
+          nextPlanCode: 'promotional-plan',
+          nextPlanName: 'Promotial plan',
+          nextPlanPrice: 8.99,
+          nextAddOns: [],
+        })
+        expect(change.tax).to.equal(1.35)
+        expect(change.total).to.equal(10.34)
+      })
+    })
+  })
 })
 })

+ 9 - 2
services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js

@@ -122,6 +122,12 @@ describe('SubscriptionHandler', function () {
       },
       },
     }
     }
 
 
+    this.SubscriptionLocator = {
+      promises: {
+        getUsersSubscription: sinon.stub().resolves(this.subscription),
+      },
+    }
+
     this.EmailHandler = {
     this.EmailHandler = {
       sendEmail: sinon.stub(),
       sendEmail: sinon.stub(),
       sendDeferredEmail: sinon.stub(),
       sendDeferredEmail: sinon.stub(),
@@ -142,6 +148,7 @@ describe('SubscriptionHandler', function () {
           User: this.User,
           User: this.User,
         },
         },
         './SubscriptionUpdater': this.SubscriptionUpdater,
         './SubscriptionUpdater': this.SubscriptionUpdater,
+        './SubscriptionLocator': this.SubscriptionLocator,
         './LimitationsManager': this.LimitationsManager,
         './LimitationsManager': this.LimitationsManager,
         '../Email/EmailHandler': this.EmailHandler,
         '../Email/EmailHandler': this.EmailHandler,
         '../Analytics/AnalyticsManager': this.AnalyticsManager,
         '../Analytics/AnalyticsManager': this.AnalyticsManager,
@@ -267,7 +274,7 @@ describe('SubscriptionHandler', function () {
           this.RecurlyClient.promises.applySubscriptionChangeRequest
           this.RecurlyClient.promises.applySubscriptionChangeRequest
         ).to.have.been.calledWith(
         ).to.have.been.calledWith(
           new RecurlySubscriptionChangeRequest({
           new RecurlySubscriptionChangeRequest({
-            subscriptionId: this.subscription.recurlySubscription_id,
+            subscription: this.activeRecurlyClientSubscription,
             timeframe: 'now',
             timeframe: 'now',
             planCode: this.plan_code,
             planCode: this.plan_code,
           })
           })
@@ -380,7 +387,7 @@ describe('SubscriptionHandler', function () {
           this.RecurlyClient.promises.applySubscriptionChangeRequest
           this.RecurlyClient.promises.applySubscriptionChangeRequest
         ).to.be.calledWith(
         ).to.be.calledWith(
           new RecurlySubscriptionChangeRequest({
           new RecurlySubscriptionChangeRequest({
-            subscriptionId: this.subscription.recurlySubscription_id,
+            subscription: this.activeRecurlyClientSubscription,
             timeframe: 'now',
             timeframe: 'now',
             planCode: this.plan_code,
             planCode: this.plan_code,
           })
           })

+ 18 - 0
services/web/types/currency-code.ts

@@ -0,0 +1,18 @@
+export type CurrencyCode =
+  | 'AUD'
+  | 'BRL'
+  | 'CAD'
+  | 'CHF'
+  | 'CLP'
+  | 'COP'
+  | 'DKK'
+  | 'EUR'
+  | 'GBP'
+  | 'INR'
+  | 'MXN'
+  | 'NOK'
+  | 'NZD'
+  | 'PEN'
+  | 'SEK'
+  | 'SGD'
+  | 'USD'

+ 38 - 0
services/web/types/subscription/subscription-change-preview.ts

@@ -0,0 +1,38 @@
+export type SubscriptionChangePreview = {
+  change: SubscriptionChange
+  currency: string
+  paymentMethod: string
+  immediateCharge: number
+  nextInvoice: {
+    date: string
+    plan: {
+      name: string
+      amount: number
+    }
+    addOns: AddOn[]
+    subtotal: number
+    tax: {
+      rate: number
+      amount: number
+    }
+    total: number
+  }
+}
+
+type AddOn = {
+  code: string
+  name: string
+  quantity: number
+  unitAmount: number
+  amount: number
+}
+
+type SubscriptionChange = AddOnPurchase
+
+type AddOnPurchase = {
+  type: 'add-on-purchase'
+  addOn: {
+    code: string
+    name: string
+  }
+}