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

Merge pull request #25843 from overleaf/ii-managed-users-make-unmanaged-terminate-subscription

[web] Terminate subscription when joining a managed group

GitOrigin-RevId: 2a4f2fd57e1319970780043a633fb8027593e5d4
ilkin-overleaf 1 год назад
Родитель
Сommit
d49a9e9e80

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

@@ -736,6 +736,21 @@ async function failInvoice(invoiceId) {
   await client.markInvoiceFailed(invoiceId)
   await client.markInvoiceFailed(invoiceId)
 }
 }
 
 
+async function terminateSubscriptionByUuid(subscriptionUuid) {
+  const subscription = await client.terminateSubscription(
+    'uuid-' + subscriptionUuid,
+    {
+      body: {
+        refund: 'none',
+      },
+    }
+  )
+
+  logger.debug({ subscriptionUuid }, 'subscription terminated')
+
+  return subscription
+}
+
 module.exports = {
 module.exports = {
   errors: recurly.errors,
   errors: recurly.errors,
 
 
@@ -759,6 +774,7 @@ module.exports = {
   resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
   resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
   getPastDueInvoices: callbackify(getPastDueInvoices),
   getPastDueInvoices: callbackify(getPastDueInvoices),
   failInvoice: callbackify(failInvoice),
   failInvoice: callbackify(failInvoice),
+  terminateSubscriptionByUuid: callbackify(terminateSubscriptionByUuid),
 
 
   promises: {
   promises: {
     getSubscription,
     getSubscription,
@@ -781,5 +797,6 @@ module.exports = {
     getPlan,
     getPlan,
     getPastDueInvoices,
     getPastDueInvoices,
     failInvoice,
     failInvoice,
+    terminateSubscriptionByUuid,
   },
   },
 }
 }

+ 1 - 1
services/web/app/src/Features/Subscription/TeamInvitesController.mjs

@@ -206,7 +206,7 @@ async function acceptInvite(req, res, next) {
   const subscription = await TeamInvitesHandler.promises.acceptInvite(
   const subscription = await TeamInvitesHandler.promises.acceptInvite(
     token,
     token,
     userId,
     userId,
-    { initiatorId: userId, ipAddress: req.ip }
+    req.ip
   )
   )
   const groupSSOActive = (
   const groupSSOActive = (
     await Modules.promises.hooks.fire('hasGroupSSOEnabled', subscription)
     await Modules.promises.hooks.fire('hasGroupSSOEnabled', subscription)

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

@@ -22,6 +22,7 @@ const {
   callbackifyMultiResult,
   callbackifyMultiResult,
 } = require('@overleaf/promise-utils')
 } = require('@overleaf/promise-utils')
 const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
 const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
+const RecurlyClient = require('./RecurlyClient')
 
 
 async function getInvite(token) {
 async function getInvite(token) {
   const subscription = await Subscription.findOne({
   const subscription = await Subscription.findOne({
@@ -64,11 +65,50 @@ async function importInvite(subscription, inviterName, email, token, sentAt) {
   return subscription.save()
   return subscription.save()
 }
 }
 
 
-async function acceptInvite(token, userId, auditLog) {
+async function _deleteUserSubscription(userId, ipAddress) {
+  // Delete released user subscription to make it on a free plan
+  const subscription =
+    await SubscriptionLocator.promises.getUsersSubscription(userId)
+
+  if (subscription) {
+    logger.debug(
+      {
+        subscriptionId: subscription._id,
+      },
+      'deleting user subscription'
+    )
+
+    const deleterData = {
+      id: userId,
+      ip: ipAddress,
+    }
+    await SubscriptionUpdater.promises.deleteSubscription(
+      subscription,
+      deleterData
+    )
+
+    // Terminate the subscription in Recurly
+    if (subscription.recurlySubscription_id) {
+      try {
+        await RecurlyClient.promises.terminateSubscriptionByUuid(
+          subscription.recurlySubscription_id
+        )
+      } catch (err) {
+        logger.error(
+          { err, subscriptionId: subscription._id },
+          'terminating subscription failed'
+        )
+      }
+    }
+  }
+}
+
+async function acceptInvite(token, userId, ipAddress) {
   const { invite, subscription } = await getInvite(token)
   const { invite, subscription } = await getInvite(token)
   if (!invite) {
   if (!invite) {
     throw new Errors.NotFoundError('invite not found')
     throw new Errors.NotFoundError('invite not found')
   }
   }
+  const auditLog = { initiatorId: userId, ipAddress }
 
 
   await SubscriptionUpdater.promises.addUserToGroup(
   await SubscriptionUpdater.promises.addUserToGroup(
     subscription._id,
     subscription._id,
@@ -77,6 +117,7 @@ async function acceptInvite(token, userId, auditLog) {
   )
   )
 
 
   if (subscription.managedUsersEnabled) {
   if (subscription.managedUsersEnabled) {
+    await _deleteUserSubscription(userId, ipAddress)
     await Modules.promises.hooks.fire(
     await Modules.promises.hooks.fire(
       'enrollInManagedSubscription',
       'enrollInManagedSubscription',
       userId,
       userId,

+ 16 - 0
services/web/test/unit/src/Subscription/RecurlyClientTests.js

@@ -692,4 +692,20 @@ describe('RecurlyClient', function () {
       ).to.be.rejectedWith(Error)
       ).to.be.rejectedWith(Error)
     })
     })
   })
   })
+
+  describe('terminateSubscriptionByUuid', function () {
+    it('should attempt to terminate the subscription', async function () {
+      this.client.terminateSubscription = sinon
+        .stub()
+        .resolves(this.recurlySubscription)
+      const subscription =
+        await this.RecurlyClient.promises.terminateSubscriptionByUuid(
+          this.subscription.uuid
+        )
+      expect(subscription).to.deep.equal(this.recurlySubscription)
+      expect(this.client.terminateSubscription).to.be.calledWith(
+        'uuid-' + this.subscription.uuid
+      )
+    })
+  })
 })
 })

+ 32 - 6
services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js

@@ -29,6 +29,7 @@ describe('TeamInvitesHandler', function () {
     this.subscription = {
     this.subscription = {
       id: '55153a8014829a865bbf700d',
       id: '55153a8014829a865bbf700d',
       _id: new ObjectId('55153a8014829a865bbf700d'),
       _id: new ObjectId('55153a8014829a865bbf700d'),
+      recurlySubscription_id: '1a2b3c4d5e6f7g',
       admin_id: this.manager._id,
       admin_id: this.manager._id,
       groupPlan: true,
       groupPlan: true,
       member_ids: [],
       member_ids: [],
@@ -54,6 +55,7 @@ describe('TeamInvitesHandler', function () {
     this.SubscriptionUpdater = {
     this.SubscriptionUpdater = {
       promises: {
       promises: {
         addUserToGroup: sinon.stub().resolves(),
         addUserToGroup: sinon.stub().resolves(),
+        deleteSubscription: sinon.stub().resolves(),
       },
       },
     }
     }
 
 
@@ -109,6 +111,12 @@ describe('TeamInvitesHandler', function () {
 
 
     this.Subscription.findOne.resolves(this.subscription)
     this.Subscription.findOne.resolves(this.subscription)
 
 
+    this.RecurlyClient = {
+      promises: {
+        terminateSubscriptionByUuid: sinon.stub().resolves(),
+      },
+    }
+
     this.TeamInvitesHandler = SandboxedModule.require(modulePath, {
     this.TeamInvitesHandler = SandboxedModule.require(modulePath, {
       requires: {
       requires: {
         'mongodb-legacy': { ObjectId },
         'mongodb-legacy': { ObjectId },
@@ -126,6 +134,7 @@ describe('TeamInvitesHandler', function () {
         '../../infrastructure/Modules': (this.Modules = {
         '../../infrastructure/Modules': (this.Modules = {
           promises: { hooks: { fire: sinon.stub().resolves() } },
           promises: { hooks: { fire: sinon.stub().resolves() } },
         }),
         }),
+        './RecurlyClient': this.RecurlyClient,
       },
       },
     })
     })
   })
   })
@@ -335,6 +344,8 @@ describe('TeamInvitesHandler', function () {
         email: 'tyrion@example.com',
         email: 'tyrion@example.com',
       }
       }
 
 
+      this.ipAddress = '127.0.0.1'
+
       this.UserGetter.promises.getUserByAnyEmail
       this.UserGetter.promises.getUserByAnyEmail
         .withArgs(this.user.email)
         .withArgs(this.user.email)
         .resolves(this.user)
         .resolves(this.user)
@@ -350,7 +361,8 @@ describe('TeamInvitesHandler', function () {
       it('adds the user to the team', async function () {
       it('adds the user to the team', async function () {
         await this.TeamInvitesHandler.promises.acceptInvite(
         await this.TeamInvitesHandler.promises.acceptInvite(
           'dddddddd',
           'dddddddd',
-          this.user.id
+          this.user.id,
+          this.ipAddress
         )
         )
         this.SubscriptionUpdater.promises.addUserToGroup
         this.SubscriptionUpdater.promises.addUserToGroup
           .calledWith(this.subscription._id, this.user.id)
           .calledWith(this.subscription._id, this.user.id)
@@ -360,7 +372,8 @@ describe('TeamInvitesHandler', function () {
       it('removes the invite from the subscription', async function () {
       it('removes the invite from the subscription', async function () {
         await this.TeamInvitesHandler.promises.acceptInvite(
         await this.TeamInvitesHandler.promises.acceptInvite(
           'dddddddd',
           'dddddddd',
-          this.user.id
+          this.user.id,
+          this.ipAddress
         )
         )
         this.Subscription.updateOne
         this.Subscription.updateOne
           .calledWith(
           .calledWith(
@@ -375,7 +388,8 @@ describe('TeamInvitesHandler', function () {
 
 
         await this.TeamInvitesHandler.promises.acceptInvite(
         await this.TeamInvitesHandler.promises.acceptInvite(
           'dddddddd',
           'dddddddd',
-          this.user.id
+          this.user.id,
+          this.ipAddress
         )
         )
         sinon.assert.called(
         sinon.assert.called(
           this.NotificationsBuilder.promises.groupInvitation(
           this.NotificationsBuilder.promises.groupInvitation(
@@ -389,7 +403,8 @@ describe('TeamInvitesHandler', function () {
       it('should not schedule an SSO invite reminder', async function () {
       it('should not schedule an SSO invite reminder', async function () {
         await this.TeamInvitesHandler.promises.acceptInvite(
         await this.TeamInvitesHandler.promises.acceptInvite(
           'dddddddd',
           'dddddddd',
-          this.user.id
+          this.user.id,
+          this.ipAddress
         )
         )
         sinon.assert.notCalled(this.Modules.promises.hooks.fire)
         sinon.assert.notCalled(this.Modules.promises.hooks.fire)
       })
       })
@@ -401,7 +416,17 @@ describe('TeamInvitesHandler', function () {
 
 
         await this.TeamInvitesHandler.promises.acceptInvite(
         await this.TeamInvitesHandler.promises.acceptInvite(
           'dddddddd',
           'dddddddd',
-          this.user.id
+          this.user.id,
+          this.ipAddress
+        )
+        sinon.assert.calledWith(
+          this.SubscriptionUpdater.promises.deleteSubscription,
+          this.subscription,
+          { id: this.user.id, ip: this.ipAddress }
+        )
+        sinon.assert.calledWith(
+          this.RecurlyClient.promises.terminateSubscriptionByUuid,
+          this.subscription.recurlySubscription_id
         )
         )
         sinon.assert.calledWith(
         sinon.assert.calledWith(
           this.Modules.promises.hooks.fire,
           this.Modules.promises.hooks.fire,
@@ -421,7 +446,8 @@ describe('TeamInvitesHandler', function () {
 
 
         await this.TeamInvitesHandler.promises.acceptInvite(
         await this.TeamInvitesHandler.promises.acceptInvite(
           'dddddddd',
           'dddddddd',
-          this.user.id
+          this.user.id,
+          this.ipAddress
         )
         )
         sinon.assert.calledWith(
         sinon.assert.calledWith(
           this.Modules.promises.hooks.fire,
           this.Modules.promises.hooks.fire,