diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index b5af796bb2..25332a9c34 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -736,6 +736,21 @@ async function failInvoice(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 = { errors: recurly.errors, @@ -759,6 +774,7 @@ module.exports = { resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid), getPastDueInvoices: callbackify(getPastDueInvoices), failInvoice: callbackify(failInvoice), + terminateSubscriptionByUuid: callbackify(terminateSubscriptionByUuid), promises: { getSubscription, @@ -781,5 +797,6 @@ module.exports = { getPlan, getPastDueInvoices, failInvoice, + terminateSubscriptionByUuid, }, } diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index 29ff59b868..39cc2b9655 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -206,7 +206,7 @@ async function acceptInvite(req, res, next) { const subscription = await TeamInvitesHandler.promises.acceptInvite( token, userId, - { initiatorId: userId, ipAddress: req.ip } + req.ip ) const groupSSOActive = ( await Modules.promises.hooks.fire('hasGroupSSOEnabled', subscription) diff --git a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js index a89f0612f2..f7a4908355 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js +++ b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js @@ -22,6 +22,7 @@ const { callbackifyMultiResult, } = require('@overleaf/promise-utils') const NotificationsBuilder = require('../Notifications/NotificationsBuilder') +const RecurlyClient = require('./RecurlyClient') async function getInvite(token) { const subscription = await Subscription.findOne({ @@ -64,11 +65,50 @@ async function importInvite(subscription, inviterName, email, token, sentAt) { 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) if (!invite) { throw new Errors.NotFoundError('invite not found') } + const auditLog = { initiatorId: userId, ipAddress } await SubscriptionUpdater.promises.addUserToGroup( subscription._id, @@ -77,6 +117,7 @@ async function acceptInvite(token, userId, auditLog) { ) if (subscription.managedUsersEnabled) { + await _deleteUserSubscription(userId, ipAddress) await Modules.promises.hooks.fire( 'enrollInManagedSubscription', userId, diff --git a/services/web/test/unit/src/Subscription/RecurlyClientTests.js b/services/web/test/unit/src/Subscription/RecurlyClientTests.js index 97088e9944..6194e35a5f 100644 --- a/services/web/test/unit/src/Subscription/RecurlyClientTests.js +++ b/services/web/test/unit/src/Subscription/RecurlyClientTests.js @@ -692,4 +692,20 @@ describe('RecurlyClient', function () { ).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 + ) + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js index fdd247bf96..b15232c822 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js +++ b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js @@ -29,6 +29,7 @@ describe('TeamInvitesHandler', function () { this.subscription = { id: '55153a8014829a865bbf700d', _id: new ObjectId('55153a8014829a865bbf700d'), + recurlySubscription_id: '1a2b3c4d5e6f7g', admin_id: this.manager._id, groupPlan: true, member_ids: [], @@ -54,6 +55,7 @@ describe('TeamInvitesHandler', function () { this.SubscriptionUpdater = { promises: { addUserToGroup: sinon.stub().resolves(), + deleteSubscription: sinon.stub().resolves(), }, } @@ -109,6 +111,12 @@ describe('TeamInvitesHandler', function () { this.Subscription.findOne.resolves(this.subscription) + this.RecurlyClient = { + promises: { + terminateSubscriptionByUuid: sinon.stub().resolves(), + }, + } + this.TeamInvitesHandler = SandboxedModule.require(modulePath, { requires: { 'mongodb-legacy': { ObjectId }, @@ -126,6 +134,7 @@ describe('TeamInvitesHandler', function () { '../../infrastructure/Modules': (this.Modules = { promises: { hooks: { fire: sinon.stub().resolves() } }, }), + './RecurlyClient': this.RecurlyClient, }, }) }) @@ -335,6 +344,8 @@ describe('TeamInvitesHandler', function () { email: 'tyrion@example.com', } + this.ipAddress = '127.0.0.1' + this.UserGetter.promises.getUserByAnyEmail .withArgs(this.user.email) .resolves(this.user) @@ -350,7 +361,8 @@ describe('TeamInvitesHandler', function () { it('adds the user to the team', async function () { await this.TeamInvitesHandler.promises.acceptInvite( 'dddddddd', - this.user.id + this.user.id, + this.ipAddress ) this.SubscriptionUpdater.promises.addUserToGroup .calledWith(this.subscription._id, this.user.id) @@ -360,7 +372,8 @@ describe('TeamInvitesHandler', function () { it('removes the invite from the subscription', async function () { await this.TeamInvitesHandler.promises.acceptInvite( 'dddddddd', - this.user.id + this.user.id, + this.ipAddress ) this.Subscription.updateOne .calledWith( @@ -375,7 +388,8 @@ describe('TeamInvitesHandler', function () { await this.TeamInvitesHandler.promises.acceptInvite( 'dddddddd', - this.user.id + this.user.id, + this.ipAddress ) sinon.assert.called( this.NotificationsBuilder.promises.groupInvitation( @@ -389,7 +403,8 @@ describe('TeamInvitesHandler', function () { it('should not schedule an SSO invite reminder', async function () { await this.TeamInvitesHandler.promises.acceptInvite( 'dddddddd', - this.user.id + this.user.id, + this.ipAddress ) sinon.assert.notCalled(this.Modules.promises.hooks.fire) }) @@ -401,7 +416,17 @@ describe('TeamInvitesHandler', function () { await this.TeamInvitesHandler.promises.acceptInvite( '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( this.Modules.promises.hooks.fire, @@ -421,7 +446,8 @@ describe('TeamInvitesHandler', function () { await this.TeamInvitesHandler.promises.acceptInvite( 'dddddddd', - this.user.id + this.user.id, + this.ipAddress ) sinon.assert.calledWith( this.Modules.promises.hooks.fire,