Merge pull request #2119 from overleaf/ta-subscription-deletion

Store Deleted Subscriptions

GitOrigin-RevId: c7004f1807dee6b6ec82eeb2a8fe939801ce3e8b
This commit is contained in:
Eric Mc Sween 2019-09-09 07:51:34 -04:00 committed by sharelatex
parent bc233e6eba
commit d0fc8d90e5
12 changed files with 286 additions and 32 deletions

View file

@ -361,14 +361,16 @@ module.exports = SubscriptionController = {
) { ) {
const recurlySubscription = const recurlySubscription =
req.body['expired_subscription_notification'].subscription req.body['expired_subscription_notification'].subscription
return SubscriptionHandler.recurlyCallback(recurlySubscription, function( return SubscriptionHandler.recurlyCallback(
err recurlySubscription,
) { { ip: req.ip },
function(err) {
if (err != null) { if (err != null) {
return next(err) return next(err)
} }
return res.sendStatus(200) return res.sendStatus(200)
}) }
)
} else { } else {
return res.sendStatus(200) return res.sendStatus(200)
} }

View file

@ -209,7 +209,7 @@ const SubscriptionHandler = {
}) })
}, },
recurlyCallback(recurlySubscription, callback) { recurlyCallback(recurlySubscription, requesterData, callback) {
return RecurlyWrapper.getSubscription( return RecurlyWrapper.getSubscription(
recurlySubscription.uuid, recurlySubscription.uuid,
{ includeAccount: true }, { includeAccount: true },
@ -230,6 +230,7 @@ const SubscriptionHandler = {
return SubscriptionUpdater.syncSubscription( return SubscriptionUpdater.syncSubscription(
recurlySubscription, recurlySubscription,
user != null ? user._id : undefined, user != null ? user._id : undefined,
requesterData,
callback callback
) )
}) })

View file

@ -8,6 +8,7 @@ const PlansLocator = require('./PlansLocator')
const logger = require('logger-sharelatex') const logger = require('logger-sharelatex')
const { ObjectId } = require('mongoose').Types const { ObjectId } = require('mongoose').Types
const FeaturesUpdater = require('./FeaturesUpdater') const FeaturesUpdater = require('./FeaturesUpdater')
const { DeletedSubscription } = require('../../models/DeletedSubscription')
const SubscriptionUpdater = { const SubscriptionUpdater = {
/** /**
@ -33,7 +34,11 @@ const SubscriptionUpdater = {
Subscription.update(query, update, callback) Subscription.update(query, update, callback)
}, },
syncSubscription(recurlySubscription, adminUserId, callback) { syncSubscription(recurlySubscription, adminUserId, requesterData, callback) {
if (!callback) {
callback = requesterData
requesterData = {}
}
logger.log( logger.log(
{ adminUserId, recurlySubscription }, { adminUserId, recurlySubscription },
'syncSubscription, creating new if subscription does not exist' 'syncSubscription, creating new if subscription does not exist'
@ -53,6 +58,7 @@ const SubscriptionUpdater = {
SubscriptionUpdater._updateSubscriptionFromRecurly( SubscriptionUpdater._updateSubscriptionFromRecurly(
recurlySubscription, recurlySubscription,
subscription, subscription,
requesterData,
callback callback
) )
} else { } else {
@ -70,6 +76,7 @@ const SubscriptionUpdater = {
SubscriptionUpdater._updateSubscriptionFromRecurly( SubscriptionUpdater._updateSubscriptionFromRecurly(
recurlySubscription, recurlySubscription,
subscription, subscription,
requesterData,
callback callback
) )
}) })
@ -172,11 +179,11 @@ const SubscriptionUpdater = {
Subscription.deleteOne({ 'overleaf.id': v1TeamId }, callback) Subscription.deleteOne({ 'overleaf.id': v1TeamId }, callback)
}, },
deleteSubscription(subscriptionId, callback) { deleteSubscription(subscription, deleterData, callback) {
if (callback == null) { if (callback == null) {
callback = function() {} callback = function() {}
} }
SubscriptionLocator.getSubscription(subscriptionId, function( SubscriptionLocator.getSubscription(subscription._id, function(
err, err,
subscription subscription
) { ) {
@ -187,10 +194,17 @@ const SubscriptionUpdater = {
subscription.member_ids || [] subscription.member_ids || []
) )
logger.log( logger.log(
{ subscriptionId, affectedUserIds }, { subscriptionId: subscription._id, affectedUserIds },
'deleting subscription and downgrading users' 'deleting subscription and downgrading users'
) )
Subscription.remove({ _id: ObjectId(subscriptionId) }, function(err) { SubscriptionUpdater._createDeletedSubscription(
subscription,
deleterData,
error => {
if (error) {
return callback(error)
}
Subscription.remove({ _id: subscription._id }, function(err) {
if (err != null) { if (err != null) {
return callback(err) return callback(err)
} }
@ -200,9 +214,26 @@ const SubscriptionUpdater = {
callback callback
) )
}) })
}
)
}) })
}, },
_createDeletedSubscription(subscription, deleterData, callback) {
subscription.teamInvites = []
subscription.invited_emails = []
const filter = { 'subscription._id': subscription._id }
const data = {
deleterData: {
deleterId: deleterData.id,
deleterIpAddress: deleterData.ip
},
subscription: subscription
}
const options = { upsert: true, new: true, setDefaultsOnInsert: true }
DeletedSubscription.findOneAndUpdate(filter, data, options, callback)
},
_createNewSubscription(adminUserId, callback) { _createNewSubscription(adminUserId, callback) {
logger.log({ adminUserId }, 'creating new subscription') logger.log({ adminUserId }, 'creating new subscription')
const subscription = new Subscription({ const subscription = new Subscription({
@ -212,10 +243,19 @@ const SubscriptionUpdater = {
subscription.save(err => callback(err, subscription)) subscription.save(err => callback(err, subscription))
}, },
_updateSubscriptionFromRecurly(recurlySubscription, subscription, callback) { _updateSubscriptionFromRecurly(
recurlySubscription,
subscription,
requesterData,
callback
) {
logger.log({ recurlySubscription, subscription }, 'updaing subscription') logger.log({ recurlySubscription, subscription }, 'updaing subscription')
if (recurlySubscription.state === 'expired') { if (recurlySubscription.state === 'expired') {
return SubscriptionUpdater.deleteSubscription(subscription._id, callback) return SubscriptionUpdater.deleteSubscription(
subscription,
requesterData,
callback
)
} }
subscription.recurlySubscription_id = recurlySubscription.uuid subscription.recurlySubscription_id = recurlySubscription.uuid
subscription.planCode = recurlySubscription.plan.plan_code subscription.planCode = recurlySubscription.plan.plan_code

View file

@ -0,0 +1,42 @@
const mongoose = require('mongoose')
const Settings = require('settings-sharelatex')
const { SubscriptionSchema } = require('./Subscription')
const { Schema } = mongoose
const { ObjectId } = Schema
const DeleterDataSchema = new Schema(
{
deleterId: { type: ObjectId, ref: 'User' },
deleterIpAddress: { type: String },
deletedAt: {
type: Date,
default() {
return new Date()
}
}
},
{ _id: false }
)
const DeletedSubscriptionSchema = new Schema(
{
deleterData: DeleterDataSchema,
subscription: SubscriptionSchema
},
{ collection: 'deletedSubscriptions' }
)
const conn = mongoose.createConnection(Settings.mongo.url, {
server: { poolSize: Settings.mongo.poolSize || 10 },
config: { autoIndex: false }
})
const DeletedSubscription = conn.model(
'DeletedSubscription',
DeletedSubscriptionSchema
)
mongoose.model('DeletedSubscription', DeletedSubscriptionSchema)
exports.DeletedSubscription = DeletedSubscription
exports.DeletedSubscriptionSchema = DeletedSubscriptionSchema

View file

@ -0,0 +1,73 @@
const { expect } = require('chai')
const async = require('async')
const request = require('./helpers/request')
const User = require('./helpers/User')
const RecurlySubscription = require('./helpers/RecurlySubscription')
const SubscriptionUpdater = require('../../../app/src/Features/Subscription/SubscriptionUpdater')
require('./helpers/MockV1Api')
describe('Subscriptions', function() {
describe('deletion', function() {
beforeEach(function(done) {
this.adminUser = new User()
this.memberUser = new User()
async.series(
[
cb => this.adminUser.ensureUserExists(cb),
cb => this.memberUser.ensureUserExists(cb),
cb => {
this.recurlySubscription = new RecurlySubscription({
adminId: this.adminUser._id,
memberIds: [this.memberUser._id],
invitedEmails: ['foo@bar.com'],
teamInvites: [{ email: 'foo@baz.com' }],
groupPlan: true,
state: 'expired'
})
this.subscription = this.recurlySubscription.subscription
this.recurlySubscription.ensureExists(cb)
}
],
done
)
})
it('deletes via Recurly callback', function(done) {
let url = '/user/subscription/callback'
let body = this.recurlySubscription.buildCallbackXml()
request.post({ url, body }, (error, { statusCode }) => {
if (error) {
return done(error)
}
expect(statusCode).to.equal(200)
this.subscription.expectDeleted({ ip: '127.0.0.1' }, done)
})
})
it('allows deletion when deletedSubscription exists', function(done) {
let url = '/user/subscription/callback'
let body = this.recurlySubscription.buildCallbackXml()
// create fake deletedSubscription
SubscriptionUpdater._createDeletedSubscription(
this.subscription,
{},
error => {
if (error) {
return done(error)
}
// try deleting the subscription
request.post({ url, body }, (error, { statusCode }) => {
if (error) {
return done(error)
}
expect(statusCode).to.equal(200)
this.subscription.expectDeleted({ ip: '127.0.0.1' }, done)
})
}
)
})
})
})

View file

@ -26,6 +26,14 @@ module.exports = MockRecurlyApi = {
coupons: {}, coupons: {},
addSubscription(subscription) {
this.subscriptions[subscription.uuid] = subscription
},
addAccount(account) {
this.accounts[account.id] = account
},
run() { run() {
app.get('/subscriptions/:id', (req, res, next) => { app.get('/subscriptions/:id', (req, res, next) => {
const subscription = this.subscriptions[req.params.id] const subscription = this.subscriptions[req.params.id]

View file

@ -0,0 +1,39 @@
const { ObjectId } = require('../../../../app/src/infrastructure/mongojs')
const Subscription = require('./Subscription')
const MockRecurlyApi = require('./MockRecurlyApi')
const RecurlyWrapper = require('../../../../app/src/Features/Subscription/RecurlyWrapper')
class RecurlySubscription {
constructor(options = {}) {
this.subscription = new Subscription(options)
this.uuid = ObjectId().toString()
this.accountId = this.subscription.admin_id.toString()
this.state = options.state || 'active'
}
ensureExists(callback) {
this.subscription.ensureExists(error => {
if (error) {
return callback(error)
}
MockRecurlyApi.addSubscription({
uuid: this.uuid,
account_id: this.accountId,
state: this.state
})
MockRecurlyApi.addAccount({ id: this.accountId })
callback()
})
}
buildCallbackXml() {
return RecurlyWrapper._buildXml('expired_subscription_notification', {
subscription: {
uuid: this.uuid
}
})
}
}
module.exports = RecurlySubscription

View file

@ -1,12 +1,19 @@
const { ObjectId } = require('../../../../app/src/infrastructure/mongojs') const { ObjectId } = require('../../../../app/src/infrastructure/mongojs')
const { expect } = require('chai')
const SubscriptionModel = require('../../../../app/src/models/Subscription') const SubscriptionModel = require('../../../../app/src/models/Subscription')
.Subscription .Subscription
const DeletedSubscriptionModel = require(`../../../../app/src/models/DeletedSubscription`)
.DeletedSubscription
class Subscription { class Subscription {
constructor(options = {}) { constructor(options = {}) {
this.admin_id = options.adminId || ObjectId()
this.overleaf = options.overleaf || {} this.overleaf = options.overleaf || {}
this.groupPlan = options.groupPlan this.groupPlan = options.groupPlan
this.manager_ids = [] this.manager_ids = []
this.member_ids = options.memberIds || []
this.invited_emails = options.invitedEmails || []
this.teamInvites = options.teamInvites || []
} }
ensureExists(callback) { ensureExists(callback) {
@ -32,6 +39,36 @@ class Subscription {
callback callback
) )
} }
expectDeleted(deleterData, callback) {
DeletedSubscriptionModel.find(
{ 'subscription._id': this._id },
(error, deletedSubscriptions) => {
if (error) {
return callback(error)
}
expect(deletedSubscriptions.length).to.equal(1)
const deletedSubscription = deletedSubscriptions[0]
expect(deletedSubscription.subscription.teamInvites).to.be.empty
expect(deletedSubscription.subscription.invited_emails).to.be.empty
expect(deletedSubscription.deleterData.deleterIpAddress).to.equal(
deleterData.ip
)
if (deleterData.id) {
expect(deletedSubscription.deleterData.deleterId.toString()).to.equal(
deleterData.id.toString()
)
} else {
expect(deletedSubscription.deleterData.deleterId).to.be.undefined
}
SubscriptionModel.findById(this._id, (error, subscription) => {
expect(subscription).to.be.null
callback(error)
})
}
)
}
} }
module.exports = Subscription module.exports = Subscription

View file

@ -60,7 +60,7 @@ describe('SubscriptionController', function() {
updateSubscription: sinon.stub().callsArgWith(3), updateSubscription: sinon.stub().callsArgWith(3),
reactivateSubscription: sinon.stub().callsArgWith(1), reactivateSubscription: sinon.stub().callsArgWith(1),
cancelSubscription: sinon.stub().callsArgWith(1), cancelSubscription: sinon.stub().callsArgWith(1),
recurlyCallback: sinon.stub().callsArgWith(1), recurlyCallback: sinon.stub().yields(),
startFreeTrial: sinon.stub() startFreeTrial: sinon.stub()
} }

View file

@ -76,7 +76,7 @@ describe('SubscriptionHandler', function() {
this.DropboxHandler = { unlinkAccount: sinon.stub().callsArgWith(1) } this.DropboxHandler = { unlinkAccount: sinon.stub().callsArgWith(1) }
this.SubscriptionUpdater = { this.SubscriptionUpdater = {
syncSubscription: sinon.stub().callsArgWith(2), syncSubscription: sinon.stub().yields(),
startFreeTrial: sinon.stub().callsArgWith(1) startFreeTrial: sinon.stub().callsArgWith(1)
} }
@ -391,6 +391,7 @@ describe('SubscriptionHandler', function() {
} }
return this.SubscriptionHandler.recurlyCallback( return this.SubscriptionHandler.recurlyCallback(
this.activeRecurlySubscription, this.activeRecurlySubscription,
{},
done done
) )
}) })

View file

@ -94,6 +94,9 @@ describe('SubscriptionUpdater', function() {
this.FeaturesUpdater = { this.FeaturesUpdater = {
refreshFeatures: sinon.stub().yields() refreshFeatures: sinon.stub().yields()
} }
this.DeletedSubscription = {
findOneAndUpdate: sinon.stub().yields()
}
this.SubscriptionUpdater = SandboxedModule.require(modulePath, { this.SubscriptionUpdater = SandboxedModule.require(modulePath, {
globals: { globals: {
console: console console: console
@ -111,7 +114,10 @@ describe('SubscriptionUpdater', function() {
warn() {} warn() {}
}, },
'settings-sharelatex': this.Settings, 'settings-sharelatex': this.Settings,
'./FeaturesUpdater': this.FeaturesUpdater './FeaturesUpdater': this.FeaturesUpdater,
'../../models/DeletedSubscription': {
DeletedSubscription: this.DeletedSubscription
}
} }
}) })
}) })
@ -153,7 +159,7 @@ describe('SubscriptionUpdater', function() {
) )
this.SubscriptionUpdater._updateSubscriptionFromRecurly = sinon this.SubscriptionUpdater._updateSubscriptionFromRecurly = sinon
.stub() .stub()
.callsArgWith(2) .yields()
}) })
it('should update the subscription if the user already is admin of one', function(done) { it('should update the subscription if the user already is admin of one', function(done) {
@ -214,6 +220,7 @@ describe('SubscriptionUpdater', function() {
this.SubscriptionUpdater._updateSubscriptionFromRecurly( this.SubscriptionUpdater._updateSubscriptionFromRecurly(
this.recurlySubscription, this.recurlySubscription,
this.subscription, this.subscription,
{},
err => { err => {
if (err != null) { if (err != null) {
return done(err) return done(err)
@ -238,12 +245,13 @@ describe('SubscriptionUpdater', function() {
this.SubscriptionUpdater._updateSubscriptionFromRecurly( this.SubscriptionUpdater._updateSubscriptionFromRecurly(
this.recurlySubscription, this.recurlySubscription,
this.subscription, this.subscription,
{},
err => { err => {
if (err != null) { if (err != null) {
return done(err) return done(err)
} }
this.SubscriptionUpdater.deleteSubscription this.SubscriptionUpdater.deleteSubscription
.calledWith(this.subscription._id) .calledWithMatch(this.subscription)
.should.equal(true) .should.equal(true)
done() done()
} }
@ -254,6 +262,7 @@ describe('SubscriptionUpdater', function() {
this.SubscriptionUpdater._updateSubscriptionFromRecurly( this.SubscriptionUpdater._updateSubscriptionFromRecurly(
this.recurlySubscription, this.recurlySubscription,
this.subscription, this.subscription,
{},
err => { err => {
if (err != null) { if (err != null) {
return done(err) return done(err)
@ -282,6 +291,7 @@ describe('SubscriptionUpdater', function() {
this.SubscriptionUpdater._updateSubscriptionFromRecurly( this.SubscriptionUpdater._updateSubscriptionFromRecurly(
this.recurlySubscription, this.recurlySubscription,
this.subscription, this.subscription,
{},
err => { err => {
if (err != null) { if (err != null) {
return done(err) return done(err)
@ -297,6 +307,7 @@ describe('SubscriptionUpdater', function() {
this.SubscriptionUpdater._updateSubscriptionFromRecurly( this.SubscriptionUpdater._updateSubscriptionFromRecurly(
this.recurlySubscription, this.recurlySubscription,
this.subscription, this.subscription,
{},
err => { err => {
if (err != null) { if (err != null) {
return done(err) return done(err)
@ -441,8 +452,8 @@ describe('SubscriptionUpdater', function() {
describe('deleteSubscription', function() { describe('deleteSubscription', function() {
beforeEach(function(done) { beforeEach(function(done) {
this.subscription_id = ObjectId().toString()
this.subscription = { this.subscription = {
_id: ObjectId().toString(),
mock: 'subscription', mock: 'subscription',
admin_id: ObjectId(), admin_id: ObjectId(),
member_ids: [ObjectId(), ObjectId(), ObjectId()] member_ids: [ObjectId(), ObjectId(), ObjectId()]
@ -451,18 +462,18 @@ describe('SubscriptionUpdater', function() {
.stub() .stub()
.yields(null, this.subscription) .yields(null, this.subscription)
this.FeaturesUpdater.refreshFeatures = sinon.stub().yields() this.FeaturesUpdater.refreshFeatures = sinon.stub().yields()
this.SubscriptionUpdater.deleteSubscription(this.subscription_id, done) this.SubscriptionUpdater.deleteSubscription(this.subscription, {}, done)
}) })
it('should look up the subscription', function() { it('should look up the subscription', function() {
this.SubscriptionLocator.getSubscription this.SubscriptionLocator.getSubscription
.calledWith(this.subscription_id) .calledWith(this.subscription._id)
.should.equal(true) .should.equal(true)
}) })
it('should remove the subscription', function() { it('should remove the subscription', function() {
this.SubscriptionModel.remove this.SubscriptionModel.remove
.calledWith({ _id: ObjectId(this.subscription_id) }) .calledWith({ _id: this.subscription._id })
.should.equal(true) .should.equal(true)
}) })