فهرست منبع

Merge pull request #21274 from overleaf/em-recurly-client

Separate Recurly business logic

GitOrigin-RevId: 9c3b5ce61bdc7a6a5d3f507a31dc8919c882e476
Eric Mc Sween 1 سال پیش
والد
کامیت
1acb0d1bcd

+ 12 - 8
services/web/app/src/Features/Email/EmailHandler.js

@@ -7,14 +7,10 @@ const Queues = require('../../infrastructure/Queues')
 
 const EMAIL_SETTINGS = Settings.email || {}
 
-module.exports = {
-  sendEmail: callbackify(sendEmail),
-  sendDeferredEmail,
-  promises: {
-    sendEmail,
-  },
-}
-
+/**
+ * @param {string} emailType
+ * @param {opts} any
+ */
 async function sendEmail(emailType, opts) {
   const email = EmailBuilder.buildEmail(emailType, opts)
   if (email.type === 'lifecycle' && !EMAIL_SETTINGS.lifecycle) {
@@ -35,3 +31,11 @@ function sendDeferredEmail(emailType, opts, delay) {
     logger.warn({ err, emailType, opts }, 'failed to queue deferred email')
   })
 }
+
+module.exports = {
+  sendEmail: callbackify(sendEmail),
+  sendDeferredEmail,
+  promises: {
+    sendEmail,
+  },
+}

+ 112 - 19
services/web/app/src/Features/Subscription/RecurlyClient.js

@@ -1,8 +1,19 @@
+// @ts-check
+
 const recurly = require('recurly')
 const Settings = require('@overleaf/settings')
 const logger = require('@overleaf/logger')
+const OError = require('@overleaf/o-error')
 const { callbackify } = require('util')
 const UserGetter = require('../User/UserGetter')
+const {
+  RecurlySubscription,
+  RecurlySubscriptionAddOn,
+} = require('./RecurlyEntities')
+
+/**
+ * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
+ */
 
 const recurlySettings = Settings.apis.recurly
 const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
@@ -40,25 +51,51 @@ async function createAccountForUserId(userId) {
   return account
 }
 
+/**
+ * Get a subscription from Recurly
+ *
+ * @param {string} subscriptionId
+ * @return {Promise<RecurlySubscription>}
+ */
 async function getSubscription(subscriptionId) {
-  return await client.getSubscription(subscriptionId)
-}
-
-async function getSubscriptionByUuid(subscriptionUuid) {
-  return await client.getSubscription('uuid-' + subscriptionUuid)
+  const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
+  return makeSubscription(subscription)
 }
 
-async function changeSubscription(subscriptionId, body) {
-  const change = await client.createSubscriptionChange(subscriptionId, body)
+/**
+ * Request a susbcription change from Recurly
+ *
+ * @param {RecurlySubscriptionChangeRequest} 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 change = await client.createSubscriptionChange(
+    `uuid-${changeRequest.subscriptionId}`,
+    body
+  )
   logger.debug(
-    { subscriptionId, changeId: change.id },
+    { subscriptionId: changeRequest.subscriptionId, changeId: change.id },
     'created subscription change'
   )
-  return change
-}
-
-async function changeSubscriptionByUuid(subscriptionUuid, ...args) {
-  return await changeSubscription('uuid-' + subscriptionUuid, ...args)
 }
 
 async function removeSubscriptionChange(subscriptionId) {
@@ -99,15 +136,73 @@ function subscriptionIsCanceledOrExpired(subscription) {
   return state === 'canceled' || state === 'expired'
 }
 
+/**
+ * Build a RecurlySubscription from Recurly API data
+ *
+ * @param {recurly.Subscription} subscription
+ * @return {RecurlySubscription}
+ */
+function makeSubscription(subscription) {
+  if (
+    subscription.uuid == null ||
+    subscription.plan == null ||
+    subscription.plan.code == null ||
+    subscription.plan.name == null ||
+    subscription.account == null ||
+    subscription.account.code == null ||
+    subscription.unitAmount == null ||
+    subscription.subtotal == null ||
+    subscription.total == null ||
+    subscription.currency == null
+  ) {
+    throw new OError('Invalid Recurly subscription', { subscription })
+  }
+  return new RecurlySubscription({
+    id: subscription.uuid,
+    userId: subscription.account.code,
+    planCode: subscription.plan.code,
+    planName: subscription.plan.name,
+    planPrice: subscription.unitAmount,
+    addOns: (subscription.addOns ?? []).map(makeSubscriptionAddOn),
+    subtotal: subscription.subtotal,
+    taxRate: subscription.taxInfo?.rate ?? 0,
+    taxAmount: subscription.tax ?? 0,
+    total: subscription.total,
+    currency: subscription.currency,
+  })
+}
+
+/**
+ * Build a RecurlySubscriptionAddOn from Recurly API data
+ *
+ * @param {recurly.SubscriptionAddOn} addOn
+ * @return {RecurlySubscriptionAddOn}
+ */
+function makeSubscriptionAddOn(addOn) {
+  if (
+    addOn.addOn == null ||
+    addOn.addOn.code == null ||
+    addOn.addOn.name == null ||
+    addOn.unitAmount == null
+  ) {
+    throw new OError('Invalid Recurly add-on', { addOn })
+  }
+
+  return new RecurlySubscriptionAddOn({
+    code: addOn.addOn.code,
+    name: addOn.addOn.name,
+    quantity: addOn.quantity ?? 1,
+    unitPrice: addOn.unitAmount,
+  })
+}
+
 module.exports = {
   errors: recurly.errors,
 
   getAccountForUserId: callbackify(getAccountForUserId),
   createAccountForUserId: callbackify(createAccountForUserId),
   getSubscription: callbackify(getSubscription),
-  getSubscriptionByUuid: callbackify(getSubscriptionByUuid),
-  changeSubscription: callbackify(changeSubscription),
-  changeSubscriptionByUuid: callbackify(changeSubscriptionByUuid),
+  applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
   removeSubscriptionChange: callbackify(removeSubscriptionChange),
   removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
   reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
@@ -116,11 +211,9 @@ module.exports = {
 
   promises: {
     getSubscription,
-    getSubscriptionByUuid,
     getAccountForUserId,
     createAccountForUserId,
-    changeSubscription,
-    changeSubscriptionByUuid,
+    applySubscriptionChangeRequest,
     removeSubscriptionChange,
     removeSubscriptionChangeByUuid,
     reactivateSubscriptionByUuid,

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

@@ -0,0 +1,194 @@
+// @ts-check
+
+const OError = require('@overleaf/o-error')
+const { DuplicateAddOnError, AddOnNotPresentError } = require('./Errors')
+const PlansLocator = require('./PlansLocator')
+const SubscriptionHelper = require('./SubscriptionHelper')
+
+class RecurlySubscription {
+  /**
+   * @param {object} props
+   * @param {string} props.id
+   * @param {string} props.userId
+   * @param {string} props.planCode
+   * @param {string} props.planName
+   * @param {number} props.planPrice
+   * @param {RecurlySubscriptionAddOn[]} [props.addOns]
+   * @param {number} props.subtotal
+   * @param {number} [props.taxRate]
+   * @param {number} [props.taxAmount]
+   * @param {string} props.currency
+   * @param {number} props.total
+   */
+  constructor(props) {
+    this.id = props.id
+    this.userId = props.userId
+    this.planCode = props.planCode
+    this.planName = props.planName
+    this.planPrice = props.planPrice
+    this.addOns = props.addOns ?? []
+    this.subtotal = props.subtotal
+    this.taxRate = props.taxRate ?? 0
+    this.taxAmount = props.taxAmount ?? 0
+    this.currency = props.currency
+    this.total = props.total
+  }
+
+  hasAddOn(code) {
+    return this.addOns.some(addOn => addOn.code === code)
+  }
+
+  /**
+   * Change this subscription's plan
+   *
+   * @return {RecurlySubscriptionChangeRequest}
+   */
+  getRequestForPlanChange(planCode) {
+    const currentPlan = PlansLocator.findLocalPlanInSettings(this.planCode)
+    if (currentPlan == null) {
+      throw new OError('Unable to find plan in settings', {
+        planCode: this.planCode,
+      })
+    }
+    const newPlan = PlansLocator.findLocalPlanInSettings(planCode)
+    if (newPlan == null) {
+      throw new OError('Unable to find plan in settings', { planCode })
+    }
+    const changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
+      currentPlan,
+      newPlan
+    )
+    const timeframe = changeAtTermEnd ? 'term_end' : 'now'
+    return new RecurlySubscriptionChangeRequest({
+      subscriptionId: this.id,
+      timeframe,
+      planCode,
+    })
+  }
+
+  /**
+   * Purchase an add-on on this subscription
+   *
+   * @param {string} code
+   * @param {number} [quantity]
+   * @return {RecurlySubscriptionChangeRequest} - the change request to send to
+   * Recurly
+   *
+   * @throws {DuplicateAddOnError} if the add-on is already present on the subscription
+   */
+  getRequestForAddOnPurchase(code, quantity = 1) {
+    if (this.hasAddOn(code)) {
+      throw new DuplicateAddOnError('Subscription already has add-on', {
+        subscriptionId: this.id,
+        addOnCode: code,
+      })
+    }
+
+    const addOnUpdates = this.addOns.map(addOn => addOn.toAddOnUpdate())
+    addOnUpdates.push(new RecurlySubscriptionAddOnUpdate({ code, quantity }))
+    return new RecurlySubscriptionChangeRequest({
+      subscriptionId: this.id,
+      timeframe: 'now',
+      addOnUpdates,
+    })
+  }
+
+  /**
+   * Remove an add-on from this subscription
+   *
+   * @param {string} code
+   * @return {RecurlySubscriptionChangeRequest}
+   *
+   * @throws {AddOnNotPresentError} if the subscription doesn't have the add-on
+   */
+  getRequestForAddOnRemoval(code) {
+    if (!this.hasAddOn(code)) {
+      throw new AddOnNotPresentError(
+        'Subscripiton does not have add-on to remove',
+        {
+          subscriptionId: this.id,
+          addOnCode: code,
+        }
+      )
+    }
+    const addOnUpdates = this.addOns
+      .filter(addOn => addOn.code !== code)
+      .map(addOn => addOn.toAddOnUpdate())
+    return new RecurlySubscriptionChangeRequest({
+      subscriptionId: this.id,
+      timeframe: 'term_end',
+      addOnUpdates,
+    })
+  }
+}
+
+class RecurlySubscriptionAddOn {
+  /**
+   * @param {object} props
+   * @param {string} props.code
+   * @param {string} props.name
+   * @param {number} props.quantity
+   * @param {number} props.unitPrice
+   */
+  constructor(props) {
+    this.code = props.code
+    this.name = props.name
+    this.quantity = props.quantity
+    this.unitPrice = props.unitPrice
+  }
+
+  get preTaxTotal() {
+    return this.quantity * this.unitPrice
+  }
+
+  /**
+   * Return an add-on update that doesn't modify the add-on
+   */
+  toAddOnUpdate() {
+    return new RecurlySubscriptionAddOnUpdate({
+      code: this.code,
+      quantity: this.quantity,
+      unitPrice: this.unitPrice,
+    })
+  }
+}
+
+class RecurlySubscriptionChangeRequest {
+  /**
+   * @param {object} props
+   * @param {string} props.subscriptionId
+   * @param {"now" | "term_end"} props.timeframe
+   * @param {string} [props.planCode]
+   * @param {RecurlySubscriptionAddOnUpdate[]} [props.addOnUpdates]
+   */
+  constructor(props) {
+    if (props.planCode == null && props.addOnUpdates == null) {
+      throw new OError('Invalid RecurlySubscriptionChangeRequest', { props })
+    }
+    this.subscriptionId = props.subscriptionId
+    this.timeframe = props.timeframe
+    this.planCode = props.planCode ?? null
+    this.addOnUpdates = props.addOnUpdates ?? null
+  }
+}
+
+class RecurlySubscriptionAddOnUpdate {
+  /**
+   * @param {object} props
+   * @param {string} props.code
+   * @param {number} [props.quantity]
+   * @param {number} [props.unitPrice]
+   */
+  constructor(props) {
+    this.code = props.code
+    this.quantity = props.quantity ?? null
+    this.unitPrice = props.unitPrice ?? null
+  }
+}
+
+module.exports = {
+  RecurlySubscription,
+  RecurlySubscriptionAddOn,
+  RecurlySubscriptionChangeRequest,
+  RecurlySubscriptionAddOnUpdate,
+}

+ 33 - 84
services/web/app/src/Features/Subscription/SubscriptionHandler.js

@@ -1,3 +1,5 @@
+// @ts-check
+
 const RecurlyWrapper = require('./RecurlyWrapper')
 const RecurlyClient = require('./RecurlyClient')
 const { User } = require('../../models/User')
@@ -5,15 +7,13 @@ const logger = require('@overleaf/logger')
 const SubscriptionUpdater = require('./SubscriptionUpdater')
 const LimitationsManager = require('./LimitationsManager')
 const EmailHandler = require('../Email/EmailHandler')
-const PlansLocator = require('./PlansLocator')
-const SubscriptionHelper = require('./SubscriptionHelper')
 const { callbackify } = require('@overleaf/promise-utils')
 const UserUpdater = require('../User/UserUpdater')
-const {
-  DuplicateAddOnError,
-  AddOnNotPresentError,
-  NoRecurlySubscriptionError,
-} = require('./Errors')
+const { NoRecurlySubscriptionError } = require('./Errors')
+
+/**
+ * @import { RecurlySubscription } from './RecurlyEntities'
+ */
 
 async function validateNoSubscriptionInRecurly(userId) {
   let subscriptions =
@@ -76,13 +76,18 @@ async function updateSubscription(user, planCode, couponCode) {
     )
   }
 
-  if (!hasSubscription) {
+  if (
+    !hasSubscription ||
+    subscription == null ||
+    subscription.recurlySubscription_id == null
+  ) {
     return
   }
+  const recurlySubscriptionId = subscription.recurlySubscription_id
 
   if (couponCode) {
     const usersSubscription = await RecurlyWrapper.promises.getSubscription(
-      subscription.recurlySubscription_id,
+      recurlySubscriptionId,
       { includeAccount: true }
     )
 
@@ -91,47 +96,12 @@ async function updateSubscription(user, planCode, couponCode) {
       couponCode
     )
   }
-  let changeAtTermEnd
-
-  const currentPlan = PlansLocator.findLocalPlanInSettings(
-    subscription.planCode
-  )
-  const newPlan = PlansLocator.findLocalPlanInSettings(planCode)
-  if (currentPlan && newPlan) {
-    changeAtTermEnd = SubscriptionHelper.shouldPlanChangeAtTermEnd(
-      currentPlan,
-      newPlan
-    )
-  } else {
-    logger.error(
-      { currentPlan: subscription.planCode, newPlan: planCode },
-      'unable to locate both plans in settings'
-    )
-    throw new Error('unable to locate both plans in settings')
-  }
 
-  const timeframe = changeAtTermEnd ? 'term_end' : 'now'
-  const subscriptionChangeOptions = { planCode, timeframe }
-  await _updateAndSyncSubscription(
-    user,
-    subscription.recurlySubscription_id,
-    subscriptionChangeOptions
+  const recurlySubscription = await RecurlyClient.promises.getSubscription(
+    recurlySubscriptionId
   )
-}
-
-async function _updateAndSyncSubscription(
-  user,
-  recurlySubscriptionId,
-  subscriptionChangeOptions
-) {
-  await RecurlyClient.promises.changeSubscriptionByUuid(
-    recurlySubscriptionId,
-    subscriptionChangeOptions
-  )
-
-  // v2 recurly API wants a UUID, but UUID isn't included in the subscription change response
-  // we got the UUID from the DB using userHasV2Subscription() - it is the only property
-  // we need to be able to build a 'recurlySubscription' object for syncSubscription()
+  const changeRequest = recurlySubscription.getRequestForPlanChange(planCode)
+  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
   await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
 }
 
@@ -139,7 +109,7 @@ async function cancelPendingSubscriptionChange(user) {
   const { hasSubscription, subscription } =
     await LimitationsManager.promises.userHasV2Subscription(user)
 
-  if (hasSubscription) {
+  if (hasSubscription && subscription != null) {
     await RecurlyClient.promises.removeSubscriptionChangeByUuid(
       subscription.recurlySubscription_id
     )
@@ -150,7 +120,7 @@ async function cancelSubscription(user) {
   try {
     const { hasSubscription, subscription } =
       await LimitationsManager.promises.userHasV2Subscription(user)
-    if (hasSubscription) {
+    if (hasSubscription && subscription != null) {
       await RecurlyClient.promises.cancelSubscriptionByUuid(
         subscription.recurlySubscription_id
       )
@@ -178,7 +148,7 @@ async function reactivateSubscription(user) {
   try {
     const { hasSubscription, subscription } =
       await LimitationsManager.promises.userHasV2Subscription(user)
-    if (hasSubscription) {
+    if (hasSubscription && subscription != null) {
       await RecurlyClient.promises.reactivateSubscriptionByUuid(
         subscription.recurlySubscription_id
       )
@@ -269,6 +239,9 @@ async function _updateSubscriptionFromRecurly(subscription) {
   )
 }
 
+/**
+ * @return {Promise<RecurlySubscription>}
+ */
 async function _getSubscription(user) {
   const { hasSubscription = false, subscription } =
     await LimitationsManager.promises.userHasV2Subscription(user)
@@ -281,50 +254,26 @@ async function _getSubscription(user) {
   }
 
   const currentSub = await RecurlyClient.promises.getSubscription(
-    `uuid-${subscription.recurlySubscription_id}`
+    subscription.recurlySubscription_id
   )
   return currentSub
 }
 
 async function purchaseAddon(user, addOnCode, quantity) {
   const subscription = await _getSubscription(user)
-  const currentAddons = subscription?.addOns || []
-
-  const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode)
-  if (hasAddon) {
-    throw new DuplicateAddOnError('User already has add-on', {
-      userId: user._id,
-      addOnCode,
-    })
-  }
-  const addOns = [...currentAddons, { code: addOnCode, quantity }]
-  const subscriptionChangeOptions = { addOns, timeframe: 'now' }
-
-  await _updateAndSyncSubscription(
-    user,
-    subscription.uuid,
-    subscriptionChangeOptions
+  const changeRequest = subscription.getRequestForAddOnPurchase(
+    addOnCode,
+    quantity
   )
+  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
+  await syncSubscription({ uuid: subscription.id }, user._id)
 }
 
 async function removeAddon(user, addOnCode) {
   const subscription = await _getSubscription(user)
-  const currentAddons = subscription?.addOns || []
-
-  const hasAddon = currentAddons.some(addOn => addOn.addOn.code === addOnCode)
-  if (!hasAddon) {
-    throw new AddOnNotPresentError('User does not have add-on to remove', {
-      userId: user._id,
-      addOnCode,
-    })
-  }
-  const addOns = currentAddons.filter(addOn => addOn.addOn.code !== addOnCode)
-  const subscriptionChangeOptions = { addOns, timeframe: 'term_end' }
-  await _updateAndSyncSubscription(
-    user,
-    subscription.uuid,
-    subscriptionChangeOptions
-  )
+  const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode)
+  await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
+  await syncSubscription({ uuid: subscription.id }, user._id)
 }
 
 module.exports = {

+ 100 - 64
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -1,8 +1,13 @@
 const sinon = require('sinon')
 const { expect } = require('chai')
 const recurly = require('recurly')
-const modulePath = '../../../../app/src/Features/Subscription/RecurlyClient'
 const SandboxedModule = require('sandboxed-module')
+const {
+  RecurlySubscriptionChangeRequest,
+  RecurlySubscriptionAddOnUpdate,
+} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
+
+const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
 
 describe('RecurlyClient', function () {
   beforeEach(function () {
@@ -13,20 +18,62 @@ describe('RecurlyClient', function () {
           privateKey: 'private_nonsense',
         },
       },
+      plans: [],
+      features: [],
     }
 
     this.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
-    this.subscription = {
-      id: 'subscription-123',
-      uuid: 'subscription-uuid-123',
-    }
     this.subscriptionChange = { id: 'subscription-change-123' }
-
     this.recurlyAccount = new recurly.Account()
     Object.assign(this.recurlyAccount, { code: this.user._id })
 
-    this.recurlySubscription = new recurly.Subscription()
-    Object.assign(this.recurlySubscription, this.subscription)
+    this.subscriptionAddOn = {
+      code: 'addon-code',
+      name: 'My Add-On',
+      quantity: 1,
+      unitPrice: 2,
+    }
+
+    this.subscription = {
+      id: 'subscription-id',
+      userId: 'user-id',
+      currency: 'EUR',
+      planCode: 'plan-code',
+      planName: 'plan-name',
+      planPrice: 13,
+      addOns: [this.subscriptionAddOn],
+      subtotal: 15,
+      taxRate: 0.1,
+      taxAmount: 1.5,
+      total: 16.5,
+    }
+
+    this.recurlySubscription = {
+      uuid: this.subscription.id,
+      account: {
+        code: this.subscription.userId,
+      },
+      plan: {
+        code: this.subscription.planCode,
+        name: this.subscription.planName,
+      },
+      addOns: [
+        {
+          addOn: {
+            code: this.subscriptionAddOn.code,
+            name: this.subscriptionAddOn.name,
+          },
+          quantity: this.subscriptionAddOn.quantity,
+          unitAmount: this.subscriptionAddOn.unitPrice,
+        },
+      ],
+      unitAmount: this.subscription.planPrice,
+      subtotal: this.subscription.subtotal,
+      taxInfo: { rate: this.subscription.taxRate },
+      tax: this.subscription.taxAmount,
+      total: this.subscription.total,
+      currency: this.subscription.currency,
+    }
 
     this.recurlySubscriptionChange = new recurly.SubscriptionChange()
     Object.assign(this.recurlySubscriptionChange, this.subscriptionChange)
@@ -52,7 +99,7 @@ describe('RecurlyClient', function () {
       },
     }
 
-    return (this.RecurlyClient = SandboxedModule.require(modulePath, {
+    return (this.RecurlyClient = SandboxedModule.require(MODULE_PATH, {
       globals: {
         console,
       },
@@ -130,12 +177,12 @@ describe('RecurlyClient', function () {
     it('should return the subscription found by recurly', async function () {
       this.client.getSubscription = sinon
         .stub()
+        .withArgs('uuid-subscription-id')
         .resolves(this.recurlySubscription)
-      await expect(
-        this.RecurlyClient.promises.getSubscription(this.subscription.id)
+      const subscription = await this.RecurlyClient.promises.getSubscription(
+        this.subscription.id
       )
-        .to.eventually.be.an.instanceOf(recurly.Subscription)
-        .that.has.property('id', this.subscription.id)
+      expect(subscription).to.deep.equal(this.subscription)
     })
 
     it('should throw any API errors', async function () {
@@ -146,69 +193,58 @@ describe('RecurlyClient', function () {
     })
   })
 
-  describe('changeSubscription', function () {
+  describe('applySubscriptionChangeRequest', function () {
     beforeEach(function () {
       this.client.createSubscriptionChange = sinon
         .stub()
         .resolves(this.recurlySubscriptionChange)
     })
 
-    it('should attempt to create a subscription change', async function () {
-      this.RecurlyClient.promises.changeSubscription(this.subscription.id, {})
+    it('handles plan changes', async function () {
+      await this.RecurlyClient.promises.applySubscriptionChangeRequest(
+        new RecurlySubscriptionChangeRequest({
+          subscriptionId: this.subscription.id,
+          timeframe: 'now',
+          planCode: 'new-plan',
+        })
+      )
       expect(this.client.createSubscriptionChange).to.be.calledWith(
-        this.subscription.id
+        'uuid-subscription-id',
+        { timeframe: 'now', planCode: 'new-plan' }
       )
     })
 
-    it('should return the subscription change event', async function () {
-      await expect(
-        this.RecurlyClient.promises.changeSubscription(
-          this.subscriptionChange.id,
-          {}
-        )
+    it('handles add-on changes', async function () {
+      await this.RecurlyClient.promises.applySubscriptionChangeRequest(
+        new RecurlySubscriptionChangeRequest({
+          subscriptionId: this.subscription.id,
+          timeframe: 'now',
+          addOnUpdates: [
+            new RecurlySubscriptionAddOnUpdate({
+              code: 'new-add-on',
+              quantity: 2,
+              unitPrice: 8.99,
+            }),
+          ],
+        })
+      )
+      expect(this.client.createSubscriptionChange).to.be.calledWith(
+        'uuid-subscription-id',
+        {
+          timeframe: 'now',
+          addOns: [{ code: 'new-add-on', quantity: 2, unitAmount: 8.99 }],
+        }
       )
-        .to.eventually.be.an.instanceOf(recurly.SubscriptionChange)
-        .that.has.property('id', this.subscriptionChange.id)
     })
 
     it('should throw any API errors', async function () {
       this.client.createSubscriptionChange = sinon.stub().throws()
       await expect(
-        this.RecurlyClient.promises.changeSubscription(this.subscription.id, {})
-      ).to.eventually.be.rejectedWith(Error)
-    })
-
-    describe('changeSubscriptionByUuid', function () {
-      it('should attempt to create a subscription change', async function () {
-        this.RecurlyClient.promises.changeSubscriptionByUuid(
-          this.subscription.uuid,
+        this.RecurlyClient.promises.applySubscriptionChangeRequest(
+          this.subscription.id,
           {}
         )
-        expect(this.client.createSubscriptionChange).to.be.calledWith(
-          'uuid-' + this.subscription.uuid
-        )
-      })
-
-      it('should return the subscription change event', async function () {
-        await expect(
-          this.RecurlyClient.promises.changeSubscriptionByUuid(
-            this.subscriptionChange.id,
-            {}
-          )
-        )
-          .to.eventually.be.an.instanceOf(recurly.SubscriptionChange)
-          .that.has.property('id', this.subscriptionChange.id)
-      })
-
-      it('should throw any API errors', async function () {
-        this.client.createSubscriptionChange = sinon.stub().throws()
-        await expect(
-          this.RecurlyClient.promises.changeSubscriptionByUuid(
-            this.subscription.id,
-            {}
-          )
-        ).to.eventually.be.rejectedWith(Error)
-      })
+      ).to.eventually.be.rejectedWith(Error)
     })
   })
 
@@ -265,11 +301,11 @@ describe('RecurlyClient', function () {
       this.client.reactivateSubscription = sinon
         .stub()
         .resolves(this.recurlySubscription)
-      await expect(
-        this.RecurlyClient.promises.reactivateSubscriptionByUuid(
+      const subscription =
+        await this.RecurlyClient.promises.reactivateSubscriptionByUuid(
           this.subscription.uuid
         )
-      ).to.eventually.be.an.instanceOf(recurly.Subscription)
+      expect(subscription).to.deep.equal(this.recurlySubscription)
       expect(this.client.reactivateSubscription).to.be.calledWith(
         'uuid-' + this.subscription.uuid
       )
@@ -281,11 +317,11 @@ describe('RecurlyClient', function () {
       this.client.cancelSubscription = sinon
         .stub()
         .resolves(this.recurlySubscription)
-      await expect(
-        this.RecurlyClient.promises.cancelSubscriptionByUuid(
+      const subscription =
+        await this.RecurlyClient.promises.cancelSubscriptionByUuid(
           this.subscription.uuid
         )
-      ).to.eventually.be.an.instanceOf(recurly.Subscription)
+      expect(subscription).to.deep.equal(this.recurlySubscription)
       expect(this.client.cancelSubscription).to.be.calledWith(
         'uuid-' + this.subscription.uuid
       )

+ 206 - 0
services/web/test/unit/src/Subscription/RecurlyEntitiesTest.js

@@ -0,0 +1,206 @@
+// @ts-check
+
+const SandboxedModule = require('sandboxed-module')
+const { expect } = require('chai')
+const Errors = require('../../../../app/src/Features/Subscription/Errors')
+const {
+  RecurlySubscriptionChangeRequest,
+} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
+
+const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyEntities'
+
+describe('RecurlyEntities', function () {
+  describe('RecurlySubscription', function () {
+    beforeEach(function () {
+      this.Settings = {
+        plans: [
+          { planCode: 'cheap-plan', price_in_cents: 500 },
+          { planCode: 'regular-plan', price_in_cents: 1000 },
+          { planCode: 'premium-plan', price_in_cents: 2000 },
+        ],
+        features: [],
+      }
+
+      this.RecurlyEntities = SandboxedModule.require(MODULE_PATH, {
+        requires: {
+          '@overleaf/settings': this.Settings,
+          './Errors': Errors,
+        },
+      })
+    })
+
+    describe('with add-ons', function () {
+      beforeEach(function () {
+        const { RecurlySubscription, RecurlySubscriptionAddOn } =
+          this.RecurlyEntities
+        this.addOn = new RecurlySubscriptionAddOn({
+          code: 'add-on-code',
+          name: 'My Add-On',
+          quantity: 1,
+          unitPrice: 2,
+        })
+        this.subscription = new RecurlySubscription({
+          id: 'subscription-id',
+          userId: 'user-id',
+          planCode: 'regular-plan',
+          planName: 'My Plan',
+          planPrice: 10,
+          addOns: [this.addOn],
+          subtotal: 10.99,
+          taxRate: 0.2,
+          taxAmount: 2.4,
+          total: 14.4,
+          currency: 'USD',
+        })
+      })
+
+      describe('hasAddOn()', function () {
+        it('returns true if the subscription has the given add-on', function () {
+          expect(this.subscription.hasAddOn(this.addOn.code)).to.be.true
+        })
+
+        it("returns false if the subscription doesn't have the given add-on", function () {
+          expect(this.subscription.hasAddOn('another-add-on')).to.be.false
+        })
+      })
+
+      describe('getRequestForPlanChange()', function () {
+        it('returns a change request for upgrades', function () {
+          const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+          const changeRequest =
+            this.subscription.getRequestForPlanChange('premium-plan')
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscriptionId: this.subscription.id,
+              timeframe: 'now',
+              planCode: 'premium-plan',
+            })
+          )
+        })
+
+        it('returns a change request for downgrades', function () {
+          const { RecurlySubscriptionChangeRequest } = this.RecurlyEntities
+          const changeRequest =
+            this.subscription.getRequestForPlanChange('cheap-plan')
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscriptionId: this.subscription.id,
+              timeframe: 'term_end',
+              planCode: 'cheap-plan',
+            })
+          )
+        })
+      })
+
+      describe('getRequestForAddOnPurchase()', function () {
+        it('returns a change request', function () {
+          const {
+            RecurlySubscriptionChangeRequest,
+            RecurlySubscriptionAddOnUpdate,
+          } = this.RecurlyEntities
+          const changeRequest =
+            this.subscription.getRequestForAddOnPurchase('another-add-on')
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscriptionId: this.subscription.id,
+              timeframe: 'now',
+              addOnUpdates: [
+                new RecurlySubscriptionAddOnUpdate({
+                  code: this.addOn.code,
+                  quantity: this.addOn.quantity,
+                  unitPrice: this.addOn.unitPrice,
+                }),
+                new RecurlySubscriptionAddOnUpdate({
+                  code: 'another-add-on',
+                  quantity: 1,
+                }),
+              ],
+            })
+          )
+        })
+
+        it('throws a DuplicateAddOnError if the subscription already has the add-on', function () {
+          expect(() =>
+            this.subscription.getRequestForAddOnPurchase(this.addOn.code)
+          ).to.throw(Errors.DuplicateAddOnError)
+        })
+      })
+
+      describe('getRequestForAddOnRemoval()', function () {
+        it('returns a change request', function () {
+          const changeRequest = this.subscription.getRequestForAddOnRemoval(
+            this.addOn.code
+          )
+          expect(changeRequest).to.deep.equal(
+            new RecurlySubscriptionChangeRequest({
+              subscriptionId: this.subscription.id,
+              timeframe: 'term_end',
+              addOnUpdates: [],
+            })
+          )
+        })
+
+        it("throws an AddOnNotPresentError if the subscription doesn't have the add-on", function () {
+          expect(() =>
+            this.subscription.getRequestForAddOnRemoval('another-add-on')
+          ).to.throw(Errors.AddOnNotPresentError)
+        })
+      })
+
+      describe('without add-ons', function () {
+        beforeEach(function () {
+          const { RecurlySubscription } = this.RecurlyEntities
+          this.subscription = new RecurlySubscription({
+            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 {
+              RecurlySubscriptionChangeRequest,
+              RecurlySubscriptionAddOnUpdate,
+            } = this.RecurlyEntities
+            const changeRequest =
+              this.subscription.getRequestForAddOnPurchase('some-add-on')
+            expect(changeRequest).to.deep.equal(
+              new RecurlySubscriptionChangeRequest({
+                subscriptionId: this.subscription.id,
+                timeframe: 'now',
+                addOnUpdates: [
+                  new RecurlySubscriptionAddOnUpdate({
+                    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)
+          })
+        })
+      })
+    })
+  })
+})

+ 95 - 122
services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js

@@ -2,6 +2,10 @@ const SandboxedModule = require('sandboxed-module')
 const sinon = require('sinon')
 const chai = require('chai')
 const { expect } = chai
+const {
+  RecurlySubscription,
+  RecurlySubscriptionChangeRequest,
+} = require('../../../../app/src/Features/Subscription/RecurlyEntities')
 
 const MODULE_PATH =
   '../../../../app/src/Features/Subscription/SubscriptionHandler'
@@ -23,20 +27,16 @@ const mockRecurlySubscriptions = {
 }
 
 const mockRecurlyClientSubscriptions = {
-  'subscription-123-active': {
-    id: 'subscription-123-recurly-id',
-    uuid: 'subscription-123-active',
-    plan: {
-      name: 'Gold',
-      code: 'gold',
-    },
-    currentPeriodEndsAt: new Date(),
-    state: 'active',
-    unitAmount: 10,
-    account: {
-      code: 'user-123',
-    },
-  },
+  'subscription-123-active': new RecurlySubscription({
+    id: 'subscription-123-active',
+    userId: 'user-id',
+    planCode: 'collaborator',
+    planName: 'Collaborator',
+    planPrice: 10,
+    subtotal: 10,
+    currency: 'USD',
+    total: 10,
+  }),
 }
 
 const mockSubscriptionChanges = {
@@ -53,11 +53,16 @@ describe('SubscriptionHandler', function () {
         {
           planCode: 'collaborator',
           name: 'Collaborator',
+          price_in_cents: 1000,
           features: {
             collaborators: -1,
             versioning: true,
           },
         },
+        {
+          planCode: 'professional',
+          price_in_cents: 1500,
+        },
       ],
       defaultPlanCode: {
         collaborators: 0,
@@ -94,7 +99,7 @@ describe('SubscriptionHandler', function () {
           .stub()
           .resolves(this.activeRecurlyClientSubscription),
         cancelSubscriptionByUuid: sinon.stub().resolves(),
-        changeSubscriptionByUuid: sinon
+        applySubscriptionChangeRequest: sinon
           .stub()
           .resolves(this.activeRecurlySubscriptionChange),
         getSubscription: sinon
@@ -122,14 +127,6 @@ describe('SubscriptionHandler', function () {
       sendDeferredEmail: sinon.stub(),
     }
 
-    this.PlansLocator = {
-      findLocalPlanInSettings: sinon.stub().returns({ planCode: 'plan' }),
-    }
-
-    this.SubscriptionHelper = {
-      shouldPlanChangeAtTermEnd: sinon.stub(),
-    }
-
     this.UserUpdater = {
       promises: {
         updateUser: sinon.stub().resolves(),
@@ -148,8 +145,6 @@ describe('SubscriptionHandler', function () {
         './LimitationsManager': this.LimitationsManager,
         '../Email/EmailHandler': this.EmailHandler,
         '../Analytics/AnalyticsManager': this.AnalyticsManager,
-        './PlansLocator': this.PlansLocator,
-        './SubscriptionHelper': this.SubscriptionHelper,
         '../User/UserUpdater': this.UserUpdater,
       },
     })
@@ -245,106 +240,81 @@ describe('SubscriptionHandler', function () {
     })
   })
 
-  function shouldUpdateSubscription() {
-    it('should update the subscription', function () {
-      expect(
-        this.RecurlyClient.promises.changeSubscriptionByUuid
-      ).to.have.been.calledWith(this.subscription.recurlySubscription_id)
-      const updateOptions =
-        this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1]
-      updateOptions.planCode.should.equal(this.plan_code)
-    })
-  }
-
-  function shouldSyncSubscription() {
-    it('should sync the new subscription to the user', function () {
-      expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been
-        .called
-
-      this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
-        this.activeRecurlySubscription
-      )
-      this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
-        this.user._id
-      )
-    })
-  }
-
-  function testUserWithASubscription(shouldPlanChangeAtTermEnd, timeframe) {
-    describe(
-      'when change should happen with timeframe ' + timeframe,
-      function () {
-        beforeEach(async function () {
-          this.user.id = this.activeRecurlySubscription.account.account_code
-          this.User.findById = (userId, projection) => ({
-            exec: () => {
-              userId.should.equal(this.user.id)
-              return Promise.resolve(this.user)
-            },
-          })
-          this.plan_code = 'collaborator'
-          this.SubscriptionHelper.shouldPlanChangeAtTermEnd.returns(
-            shouldPlanChangeAtTermEnd
-          )
-          this.LimitationsManager.promises.userHasV2Subscription.resolves({
-            hasSubscription: true,
-            subscription: this.subscription,
-          })
-          await this.SubscriptionHandler.promises.updateSubscription(
-            this.user,
-            this.plan_code,
-            null
-          )
+  describe('updateSubscription', function () {
+    describe('with a user with a subscription', function () {
+      beforeEach(async function () {
+        this.user.id = this.activeRecurlySubscription.account.account_code
+        this.User.findById = (userId, projection) => ({
+          exec: () => {
+            userId.should.equal(this.user.id)
+            return Promise.resolve(this.user)
+          },
         })
-
-        shouldUpdateSubscription()
-        shouldSyncSubscription()
-
-        it('should update with timeframe ' + timeframe, function () {
-          const updateOptions =
-            this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1]
-          updateOptions.timeframe.should.equal(timeframe)
+        this.plan_code = 'professional'
+        this.LimitationsManager.promises.userHasV2Subscription.resolves({
+          hasSubscription: true,
+          subscription: this.subscription,
         })
-      }
-    )
-  }
+        await this.SubscriptionHandler.promises.updateSubscription(
+          this.user,
+          this.plan_code,
+          null
+        )
+      })
 
-  describe('updateSubscription', function () {
-    describe('with a user with a subscription', function () {
-      testUserWithASubscription(false, 'now')
-      testUserWithASubscription(true, 'term_end')
-
-      describe('when plan(s) could not be located in settings', function () {
-        beforeEach(async function () {
-          this.user.id = this.activeRecurlySubscription.account.account_code
-          this.User.findById = (userId, projection) => ({
-            exec: () => {
-              userId.should.equal(this.user.id)
-              return Promise.resolve(this.user)
-            },
+      it('should update the subscription', function () {
+        expect(
+          this.RecurlyClient.promises.applySubscriptionChangeRequest
+        ).to.have.been.calledWith(
+          new RecurlySubscriptionChangeRequest({
+            subscriptionId: this.subscription.recurlySubscription_id,
+            timeframe: 'now',
+            planCode: this.plan_code,
           })
+        )
+      })
 
-          this.plan_code = 'collaborator'
-          this.PlansLocator.findLocalPlanInSettings.returns(null)
-          this.LimitationsManager.promises.userHasV2Subscription.resolves({
-            hasSubscription: true,
-            subscription: this.subscription,
-          })
+      it('should sync the new subscription to the user', function () {
+        expect(this.SubscriptionUpdater.promises.syncSubscription).to.have.been
+          .called
+
+        this.SubscriptionUpdater.promises.syncSubscription.args[0][0].should.deep.equal(
+          this.activeRecurlySubscription
+        )
+        this.SubscriptionUpdater.promises.syncSubscription.args[0][1].should.deep.equal(
+          this.user._id
+        )
+      })
+    })
+
+    describe('when plan(s) could not be located in settings', function () {
+      beforeEach(async function () {
+        this.user.id = this.activeRecurlySubscription.account.account_code
+        this.User.findById = (userId, projection) => ({
+          exec: () => {
+            userId.should.equal(this.user.id)
+            return Promise.resolve(this.user)
+          },
         })
 
-        it('should be rejected and should not update the subscription', function () {
-          expect(
-            this.SubscriptionHandler.promises.updateSubscription(
-              this.user,
-              this.plan_code,
-              null
-            )
-          ).to.be.rejected
-          this.RecurlyClient.promises.changeSubscriptionByUuid.called.should.equal(
-            false
-          )
+        this.LimitationsManager.promises.userHasV2Subscription.resolves({
+          hasSubscription: true,
+          subscription: this.subscription,
         })
       })
+
+      it('should be rejected and should not update the subscription', function () {
+        expect(
+          this.SubscriptionHandler.promises.updateSubscription(
+            this.user,
+            'unknown-plan',
+            null
+          )
+        ).to.be.rejected
+        this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal(
+          false
+        )
+      })
     })
 
     describe('with a user without a subscription', function () {
@@ -358,7 +328,7 @@ describe('SubscriptionHandler', function () {
       })
 
       it('should redirect to the subscription dashboard', function () {
-        this.RecurlyClient.promises.changeSubscriptionByUuid.called.should.equal(
+        this.RecurlyClient.promises.applySubscriptionChangeRequest.called.should.equal(
           false
         )
         this.SubscriptionUpdater.promises.syncSubscription.called.should.equal(
@@ -407,11 +377,14 @@ describe('SubscriptionHandler', function () {
 
       it('should update the subscription', function () {
         expect(
-          this.RecurlyClient.promises.changeSubscriptionByUuid
-        ).to.be.calledWith(this.subscription.recurlySubscription_id)
-        const updateOptions =
-          this.RecurlyClient.promises.changeSubscriptionByUuid.args[0][1]
-        updateOptions.planCode.should.equal(this.plan_code)
+          this.RecurlyClient.promises.applySubscriptionChangeRequest
+        ).to.be.calledWith(
+          new RecurlySubscriptionChangeRequest({
+            subscriptionId: this.subscription.recurlySubscription_id,
+            timeframe: 'now',
+            planCode: this.plan_code,
+          })
+        )
       })
     })
   })