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

Merge pull request #1130 from sharelatex/ja-subscription-dashboard

Refactor subscription dashboard

GitOrigin-RevId: 3573822b8b48c7181c661b2c253d7713f4a4328c
James Allen 8 лет назад
Родитель
Сommit
0f1c732d15
26 измененных файлов с 625 добавлено и 628 удалено
  1. 1 1
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  2. 16 42
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  3. 1 0
      services/web/app/coffee/Features/Subscription/SubscriptionFormatters.coffee
  4. 0 2
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  5. 0 4
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  6. 52 37
      services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
  7. 0 5
      services/web/app/coffee/models/Subscription.coffee
  8. 0 11
      services/web/app/coffee/models/User.coffee
  9. 0 15
      services/web/app/views/subscriptions/custom_account.pug
  10. 16 190
      services/web/app/views/subscriptions/dashboard.pug
  11. 21 0
      services/web/app/views/subscriptions/dashboard/_change_plans_mixins.pug
  12. 25 0
      services/web/app/views/subscriptions/dashboard/_group_memberships.pug
  13. 13 0
      services/web/app/views/subscriptions/dashboard/_personal_subscription.pug
  14. 1 0
      services/web/app/views/subscriptions/dashboard/_personal_subscription_custom.pug
  15. 93 0
      services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug
  16. 16 0
      services/web/app/views/subscriptions/dashboard/_v1_subscriptions.pug
  17. 3 3
      services/web/app/views/subscriptions/successful_subscription.pug
  18. 84 157
      services/web/public/src/main/subscription-dashboard.js
  19. 191 0
      services/web/test/acceptance/coffee/SubscriptionTests.coffee
  20. 46 0
      services/web/test/acceptance/coffee/helpers/MockRecurlyApi.coffee
  21. 12 0
      services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
  22. 8 0
      services/web/test/acceptance/coffee/helpers/User.coffee
  23. 6 0
      services/web/test/acceptance/config/settings.test.coffee
  24. 20 83
      services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee
  25. 0 8
      services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
  26. 0 70
      services/web/test/unit/coffee/Subscription/SubscriptionViewModelBuilderTests.coffee

+ 1 - 1
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -7,7 +7,7 @@ logger = require("logger-sharelatex")
 Async = require('async')
 
 module.exports = RecurlyWrapper =
-	apiUrl : "https://api.recurly.com/v2"
+	apiUrl : Settings.apis?.recurly?.url or "https://api.recurly.com/v2"
 
 	_addressToXml: (address) ->
 		allowedKeys = ['address1', 'address2', 'city', 'country', 'state', 'zip', 'postal_code']

+ 16 - 42
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -89,47 +89,19 @@ module.exports = SubscriptionController =
 
 	userSubscriptionPage: (req, res, next) ->
 		user = AuthenticationController.getSessionUser(req)
-		LimitationsManager.hasPaidSubscription user, (err, hasPaidSubscription, subscription)->
-			return next(err) if err?
-			groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user)
-			if subscription?.customAccount
-				logger.log user: user, "redirecting to custom account page"
-				res.redirect "/user/subscription/custom_account"
-			else if groupLicenceInviteUrl? and !hasPaidSubscription
-				logger.log user:user, "redirecting to group subscription invite page"
-				res.redirect groupLicenceInviteUrl
-			else if !hasPaidSubscription
-				logger.log user: user, "redirecting to plans"
-				res.redirect "/user/subscription/plans"
-			else
-				SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription, groupSubscriptions, billingDetailsLink, v1Subscriptions) ->
-					return next(error) if error?
-					logger.log {user, subscription, hasPaidSubscription, groupSubscriptions, billingDetailsLink, v1Subscriptions}, "showing subscription dashboard"
-					plans = SubscriptionViewModelBuilder.buildViewModel()
-					res.render "subscriptions/dashboard",
-						title: "your_subscription"
-						recomendedCurrency: subscription?.currency
-						taxRate:subscription?.taxRate
-						plans: plans
-						subscription: subscription || {}
-						groupSubscriptions: groupSubscriptions
-						subscriptionTabActive: true
-						user:user
-						saved_billing_details: req.query.saved_billing_details?
-						billingDetailsLink: billingDetailsLink
-						v1Subscriptions: v1Subscriptions
-
-	userCustomSubscriptionPage: (req, res, next)->
-		user = AuthenticationController.getSessionUser(req)
-		LimitationsManager.hasPaidSubscription user, (err, hasPaidSubscription, subscription)->
-			return next(err) if err?
-			if !subscription?
-				err = new Error("subscription null for custom account, user:#{user?._id}")
-				logger.warn err:err, "subscription is null for custom accounts page"
-				return next(err)
-			res.render "subscriptions/custom_account",
+		SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, results) ->
+			return next(error) if error?
+			{ personalSubscription, groupSubscriptions, v1Subscriptions } = results
+			logger.log {user, personalSubscription, groupSubscriptions, v1Subscriptions}, "showing subscription dashboard"
+			plans = SubscriptionViewModelBuilder.buildViewModel()
+			data = 
 				title: "your_subscription"
-				subscription: subscription
+				plans: plans
+				user: user
+				personalSubscription: personalSubscription
+				groupSubscriptions: groupSubscriptions
+				v1Subscriptions: v1Subscriptions
+			res.render "subscriptions/dashboard", data
 
 	createSubscription: (req, res, next)->
 		user = AuthenticationController.getSessionUser(req)
@@ -150,11 +122,13 @@ module.exports = SubscriptionController =
 
 	successful_subscription: (req, res, next)->
 		user = AuthenticationController.getSessionUser(req)
-		SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) ->
+		SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, {personalSubscription}) ->
 			return next(error) if error?
+			if !personalSubscription?
+				return res.redirect '/user/subscription/plans'
 			res.render "subscriptions/successful_subscription",
 				title: "thank_you"
-				subscription:subscription
+				subscription:personalSubscription
 
 	cancelSubscription: (req, res, next) ->
 		user = AuthenticationController.getSessionUser(req)

+ 1 - 0
services/web/app/coffee/Features/Subscription/SubscriptionFormatters.coffee

@@ -29,4 +29,5 @@ module.exports =
 		return "#{symbol}#{dollars}.#{cents}"
 
 	formatDate: (date) ->
+		return null if !date?
 		dateformat date, "dS mmmm yyyy"

+ 0 - 2
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -13,8 +13,6 @@ module.exports =
 
 		webRouter.get  '/user/subscription',            AuthenticationController.requireLogin(), SubscriptionController.userSubscriptionPage
 
-		webRouter.get  '/user/subscription/custom_account', AuthenticationController.requireLogin(), SubscriptionController.userCustomSubscriptionPage
-
 		webRouter.get  '/user/subscription/new',        AuthenticationController.requireLogin(), SubscriptionController.paymentPage
 
 		webRouter.get  '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription

+ 0 - 4
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -75,7 +75,6 @@ module.exports = SubscriptionUpdater =
 	_createNewSubscription: (adminUser_id, callback)->
 		logger.log adminUser_id:adminUser_id, "creating new subscription"
 		subscription = new Subscription(admin_id:adminUser_id, manager_ids: [adminUser_id])
-		subscription.freeTrial.allowed = false
 		subscription.save (err)->
 			callback err, subscription
 
@@ -84,9 +83,6 @@ module.exports = SubscriptionUpdater =
 		if recurlySubscription.state == "expired"
 			return SubscriptionUpdater.deleteSubscription subscription._id, callback
 		subscription.recurlySubscription_id = recurlySubscription.uuid
-		subscription.freeTrial.expiresAt = undefined
-		subscription.freeTrial.planCode = undefined
-		subscription.freeTrial.allowed = true
 		subscription.planCode = recurlySubscription.plan.plan_code
 		plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
 		if !plan?

+ 52 - 37
services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee

@@ -7,6 +7,7 @@ SubscriptionLocator = require("./SubscriptionLocator")
 V1SubscriptionManager = require("./V1SubscriptionManager")
 logger = require('logger-sharelatex')
 _ = require("underscore")
+async = require('async')
 
 
 buildBillingDetails = (recurlySubscription) ->
@@ -21,44 +22,58 @@ buildBillingDetails = (recurlySubscription) ->
 		].join("")
 
 module.exports =
-	buildUsersSubscriptionViewModel: (user, callback = (error, subscription, memberSubscriptions, billingDetailsLink) ->) ->
-		SubscriptionLocator.getUsersSubscription user, (err, subscription) ->
+	buildUsersSubscriptionViewModel: (user, callback = (error, data) ->) ->
+		async.auto {
+			personalSubscription: (cb) ->
+				SubscriptionLocator.getUsersSubscription user, cb
+			recurlySubscription: ['personalSubscription', (cb, {personalSubscription}) ->
+				if !personalSubscription?.recurlySubscription_id? or personalSubscription?.recurlySubscription_id == ''
+					return cb(null, null) 
+				RecurlyWrapper.getSubscription personalSubscription.recurlySubscription_id, includeAccount: true, cb
+			]
+			plan: ['personalSubscription', (cb, {personalSubscription}) ->
+				return cb() if !personalSubscription?
+				plan = PlansLocator.findLocalPlanInSettings(personalSubscription.planCode)
+				return cb(new Error("No plan found for planCode '#{personalSubscription.planCode}'")) if !plan?
+				cb(null, plan)
+			]
+			groupSubscriptions: (cb) ->
+				SubscriptionLocator.getMemberSubscriptions user, cb
+			v1Subscriptions: (cb) ->
+				V1SubscriptionManager.getSubscriptionsFromV1 user._id, (error, subscriptions, v1Id) ->
+					return cb(error) if error?
+					# Only return one argument to async.auto, otherwise it returns an array
+					cb(null, subscriptions)
+		}, (err, results) ->
 			return callback(err) if err?
-
-			SubscriptionLocator.getMemberSubscriptions user, (err, memberSubscriptions = []) ->
-				return callback(err) if err?
-
-				V1SubscriptionManager.getSubscriptionsFromV1 user._id, (err, v1Subscriptions) ->
-					return callback(err) if err?
-
-					if subscription?
-						return callback(error) if error?
-
-						plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
-
-						if !plan?
-							err = new Error("No plan found for planCode '#{subscription.planCode}'")
-							logger.error {user_id: user._id, err}, "error getting subscription plan for user"
-							return callback(err)
-
-						RecurlyWrapper.getSubscription subscription.recurlySubscription_id, includeAccount: true, (err, recurlySubscription)->
-							tax = recurlySubscription?.tax_in_cents || 0
-
-							callback null, {
-								admin_id:subscription.admin_id
-								name: plan.name
-								nextPaymentDueAt: SubscriptionFormatters.formatDate(recurlySubscription?.current_period_ends_at)
-								state: recurlySubscription?.state
-								price: SubscriptionFormatters.formatPrice (recurlySubscription?.unit_amount_in_cents + tax), recurlySubscription?.currency
-								planCode: subscription.planCode
-								currency:recurlySubscription?.currency
-								taxRate:parseFloat(recurlySubscription?.tax_rate?._)
-								groupPlan: subscription.groupPlan
-								trial_ends_at:recurlySubscription?.trial_ends_at
-							}, memberSubscriptions, buildBillingDetails(recurlySubscription), v1Subscriptions
-
-					else
-						callback null, null, memberSubscriptions, null, v1Subscriptions
+			{personalSubscription, groupSubscriptions, v1Subscriptions, recurlySubscription, plan} = results
+			groupSubscriptions ?= []
+			v1Subscriptions ?= {}
+
+			if personalSubscription?.toObject?
+				# Downgrade from Mongoose object, so we can add a recurly and plan attribute
+				personalSubscription = personalSubscription.toObject()
+
+			if plan?
+				personalSubscription.plan = plan
+
+			if personalSubscription? and recurlySubscription?
+				tax = recurlySubscription?.tax_in_cents || 0
+				personalSubscription.recurly = {
+					tax: tax
+					taxRate: parseFloat(recurlySubscription?.tax_rate?._)
+					billingDetailsLink: buildBillingDetails(recurlySubscription)
+					price: SubscriptionFormatters.formatPrice (recurlySubscription?.unit_amount_in_cents + tax), recurlySubscription?.currency
+					nextPaymentDueAt: SubscriptionFormatters.formatDate(recurlySubscription?.current_period_ends_at)
+					currency: recurlySubscription.currency
+					state: recurlySubscription.state
+					trialEndsAtFormatted: SubscriptionFormatters.formatDate(recurlySubscription?.trial_ends_at)
+					trial_ends_at: recurlySubscription.trial_ends_at
+				}
+
+			callback null, {
+				personalSubscription, groupSubscriptions, v1Subscriptions
+			}
 
 	buildViewModel : ->
 		plans = Settings.plans

+ 0 - 5
services/web/app/coffee/models/Subscription.coffee

@@ -18,11 +18,6 @@ SubscriptionSchema = new Schema
 	groupPlan	 : {type: Boolean, default: false}
 	membersLimit: {type:Number, default:0}
 	customAccount: Boolean
-	freeTrial:
-		expiresAt: Date
-		downgraded: Boolean
-		planCode: String
-		allowed: {type: Boolean, default: true}
 	overleaf:
 		id:
 			type: Number

+ 0 - 11
services/web/app/coffee/models/User.coffee

@@ -55,17 +55,6 @@ UserSchema = new Schema
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	refered_users: [ type:ObjectId, ref:'User' ]
 	refered_user_count: { type:Number, default: 0 }
-	subscription:
-		recurlyToken : String
-		freeTrialExpiresAt: Date
-		freeTrialDowngraded: Boolean
-		freeTrialPlanCode: String
-		# This is poorly named. It does not directly correspond
-		# to whether the user has has a free trial, but rather
-		# whether they should be allowed one in the future.
-		# For example, a user signing up directly for a paid plan
-		# has this set to true, despite never having had a free trial
-		hadFreeTrial: {type: Boolean, default: false}
 	refProviders: {
 		mendeley: Boolean  # coerce the refProviders values to Booleans
 		zotero: Boolean

+ 0 - 15
services/web/app/views/subscriptions/custom_account.pug

@@ -1,15 +0,0 @@
-extends ../layout
-
-block content
-	.content.content-alt
-		.container
-			.row
-				.col-md-8.col-md-offset-2
-					.card
-						.page-header
-							h1 #{translate("your_subscription")}
-						div To make changes to your subscription please contact accounts@sharelatex.com
-						div  
-						div
-							-if(subscription.groupPlan)
-								a(href="/subscription/group").btn.btn-success !{translate("manage_group")}

+ 16 - 190
services/web/app/views/subscriptions/dashboard.pug

@@ -1,201 +1,27 @@
 extends ../layout
-block scripts
-	script(src="https://js.recurly.com/v3/recurly.js")
-	script(type='text/javascript').
-
-		window.recomendedCurrency = '#{recomendedCurrency}'
-		window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
-		window.taxRate = #{taxRate}
-		window.subscription = !{JSON.stringify(subscription)}
-
-
-
-mixin printPlan(plan)
-	-if (!plan.hideFromUsers)
-		tr(ng-controller="ChangePlanFormController", ng-init="plan="+JSON.stringify(plan), ng-show="shouldShowPlan(plan.planCode)")
-			td
-				strong #{plan.name}
-			td {{refreshPrice(plan.planCode)}}
-				-if (plan.annual)
-					| {{prices[plan.planCode]}} / #{translate("year")}
-				-else
-					| {{prices[plan.planCode]}} / #{translate("month")}
-			td
-				-if (subscription.state == "free-trial")
-					a(href="/user/subscription/new?planCode="+plan.planCode).btn.btn-success #{translate("subscribe_to_this_plan")}
-				-else if (typeof(subscription.planCode) != "undefined" && plan.planCode == subscription.planCode.split("_")[0])
-					button.btn.disabled #{translate("your_plan")}
-				-else
-					form
-						input(type="hidden", ng-model="plan_code", name="plan_code", value=plan.planCode)
-						input(type="submit", ng-click="changePlan()", value=translate("change_to_this_plan")).btn.btn-success
-
-
-mixin printPlans(plans)
-	each plan in plans
-		+printPlan(plan)
 
 block content
 	.content.content-alt(ng-cloak)
-		.container(ng-controller="UserSubscriptionController")
+		.container
 			.row
 				.col-md-8.col-md-offset-2
-					if saved_billing_details
-						.alert.alert-success
-							i.fa.fa-check(aria-hidden="true")
-							|  
-							| #{translate("your_billing_details_were_saved")}
-					.card(ng-if="view == 'overview'")
-						.page-header(x-current-plan=subscription.planCode)
-							h1 #{translate("your_subscription")} 
-
-						- if (subscription && user._id+'' == subscription.admin_id+'')
-							case subscription.state
-								when "free-trial"
-									p !{translate("on_free_trial_expiring_at", {expiresAt:"<strong>" + subscription.expiresAt + "</strong>"})}
-									
-								when "active"
-									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})} 
-										span(ng-show="!isNextGenPlan")
-											a(href, ng-click="changePlan = true") !{translate("change_plan")}.
-									p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>" + subscription.price + "</strong>", collectionDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
-									p.pull-right
-									p
-										if billingDetailsLink
-											a(href=billingDetailsLink, target="_blank").btn.btn-info #{translate("update_your_billing_details")}
-										else
-											a(href=billingDetailsLink, disabled).btn.btn-info.btn-disabled #{translate("update_your_billing_details")}
-										| &nbsp;
-										a(href, ng-click="switchToCancelationView()").btn.btn-primary !{translate("cancel_your_subscription")}
-								when "canceled"
-									p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})}
-									p !{translate("subscription_canceled_and_terminate_on_x", {terminateDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
-									p: form(action="/user/subscription/reactivate",method="post")
-										input(type="hidden", name="_csrf", value=csrfToken)
-										input(type="submit",value="Reactivate your subscription").btn.btn-success
-								when "expired"
-									p !{translate("your_subscription_has_expired")}	
-										a(href="/user/subscription/plans") !{translate("create_new_subscription")}
-								default
-									-if(groupSubscriptions.length == 0)
-										p !{translate("problem_with_subscription_contact_us")}
-
-							div(ng-show="changePlan", ng-cloak)#changePlanSection
-								h2.col-md-7 !{translate("change_plan")}
-								span.dropdown.col-md-1.changePlanButton(ng-controller="CurrenyDropdownController", style="display:none", dropdown)
-									a.btn.btn-primary.dropdown-toggle(
-										href="#",
-										data-toggle="dropdown",
-										dropdown-toggle
-									)
-										| {{currencyCode}} ({{plans[currencyCode]['symbol']}})
-									ul.dropdown-menu(role="menu")
-										li(ng-repeat="(currency, value) in plans", dropdown-toggle)
-											a(
-												href,
-												ng-click="changeCurrency(currency)"
-											) {{currency}} ({{value['symbol']}})
-								p: table.table
-									tr
-										th !{translate("name")}
-										th !{translate("price")}
-										th
-									+printPlans(plans.studentAccounts)
-									+printPlans(plans.individualMonthlyPlans)
-									+printPlans(plans.individualAnnualPlans)
-							hr
-
-						each groupSubscription in groupSubscriptions
-							- if (user._id+'' != groupSubscription.admin_id._id+'')
-								div 
-									p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
-									span 
-										button.btn.btn-danger.text-capitalise(ng-click="removeSelfFromGroup('"+groupSubscription.admin_id._id+"')") #{translate("leave_group")}
-								hr
-
-						-if(subscription.groupPlan && user._id+'' == subscription.admin_id+'')
-							div
-								a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
-							hr
-
-						if settings.overleaf && v1Subscriptions && v1Subscriptions.has_subscription
-							p
-								| You are subscribed to Overleaf through Overleaf v1
-							p
-								a.btn.btn-primary(href='/sign_in_to_v1?return_to=/users/edit%23status') Manage v1 Subscription
-							hr
-
-						if settings.overleaf && v1Subscriptions && v1Subscriptions.teams && v1Subscriptions.teams.length > 0
-							for team in v1Subscriptions.teams
-								p
-									| You are a member of the Overleaf v1 team: <strong>#{team.name}</strong>
-							p
-								a.btn.btn-primary(href="/sign_in_to_v1?return_to=/teams") Manage v1 Team Membership
-							hr
-
-					.card(ng-if="view == 'cancelation'")
+					.card
 						.page-header
-							h1 #{translate("Cancel Subscription")}
-
-						div(ng-show="showExtendFreeTrial", style="text-align: center")
-							p !{translate("have_more_days_to_try", {days:14})}
-							button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
-							p
-								| &nbsp;
-							p
-								a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
-
-						div(ng-show="showDowngradeToStudent", style="text-align: center")
-							span(ng-controller="ChangePlanFormController")
-								p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
-								button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-success #{translate("yes_please")}
-								p
-									| &nbsp;
-								p
-									a(href, ng-click="cancelSubscription()", ng-disabled='inflight')  #{translate("no_thanks_cancel_now")}
-
-						div(ng-show="showBasicCancel")
-							p #{translate("sure_you_want_to_cancel")}
-							a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
-							| &nbsp;
-							a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
+							h1 #{translate("your_subscription")} 
 
-	script(type="text/javascript").
-		$('#cancelSubscription').on("click", function() {
-			ga('send', 'event', 'subscription-funnel', 'cancelation')
-		})
+						-var hasAnySubscription = false
+						-if (personalSubscription)
+							-hasAnySubscription = true
+							include ./dashboard/_personal_subscription
 
-	script(type='text/ng-template', id='confirmChangePlanModalTemplate')
-		.modal-header
-			h3 #{translate("change_plan")}
-		.modal-body
-			p !{translate("sure_you_want_to_change_plan", {planName:"<strong>{{plan.name}}</strong>"})}
-		.modal-footer
-			button.btn.btn-default(
-				ng-disabled="inflight"
-				ng-click="cancel()"
-			) #{translate("cancel")}
-			button.btn.btn-success(
-				ng-disabled="state.inflight"
-				ng-click="confirmChangePlan()"
-			)
-				span(ng-hide="inflight") #{translate("change_plan")}
-				span(ng-show="inflight") #{translate("processing")}...
+						-if (groupSubscriptions && groupSubscriptions.length > 0)
+							-hasAnySubscription = true
+							include ./dashboard/_group_memberships
 
+						-if (settings.overleaf && v1Subscriptions)
+							include ./dashboard/_v1_subscriptions
 
-	script(type='text/ng-template', id='LeaveGroupModalTemplate')
-		.modal-header
-			h3 #{translate("leave_group")}
-		.modal-body
-			p #{translate("sure_you_want_to_leave_group")}
-		.modal-footer
-			button.btn.btn-default(
-				ng-disabled="inflight"
-				ng-click="cancel()"
-			) #{translate("cancel")}
-			button.btn.btn-danger(
-				ng-disabled="state.inflight"
-				ng-click="confirmLeaveGroup()"
-			)
-				span(ng-hide="inflight") #{translate("leave_now")}
-				span(ng-show="inflight") #{translate("processing")}...
+						-if (!hasAnySubscription)
+							p You're on the #{settings.appName} Free plan.
+							|
+							a(href="/user/subscription/plans").btn.btn-primary Upgrade now

+ 21 - 0
services/web/app/views/subscriptions/dashboard/_change_plans_mixins.pug

@@ -0,0 +1,21 @@
+mixin printPlan(plan)
+	-if (!plan.hideFromUsers)
+		tr(ng-controller="ChangePlanFormController", ng-init="plan="+JSON.stringify(plan))
+			td
+				strong #{plan.name}
+			td
+				-if (plan.annual)
+					| {{price}} / #{translate("year")}
+				-else
+					| {{price}} / #{translate("month")}
+			td
+				-if (typeof(personalSubscription.planCode) != "undefined" && plan.planCode == personalSubscription.planCode.split("_")[0])
+					button.btn.disabled #{translate("your_plan")}
+				-else
+					form
+						input(type="hidden", ng-model="plan_code", name="plan_code", value=plan.planCode)
+						input(type="submit", ng-click="changePlan()", value=translate("change_to_this_plan")).btn.btn-success
+
+mixin printPlans(plans)
+	each plan in plans
+		+printPlan(plan)

+ 25 - 0
services/web/app/views/subscriptions/dashboard/_group_memberships.pug

@@ -0,0 +1,25 @@
+div(ng-controller="GroupMembershipController")
+	each groupSubscription in groupSubscriptions
+		- if (user._id+'' != groupSubscription.admin_id._id+'')
+			div 
+				p !{translate("member_of_group_subscription", {admin_email: "<strong>" + groupSubscription.admin_id.email + "</strong>"})}
+				span 
+					button.btn.btn-danger.text-capitalise(ng-click="removeSelfFromGroup('"+groupSubscription.admin_id._id+"')") #{translate("leave_group")}
+			hr
+
+script(type='text/ng-template', id='LeaveGroupModalTemplate')
+	.modal-header
+		h3 #{translate("leave_group")}
+	.modal-body
+		p #{translate("sure_you_want_to_leave_group")}
+	.modal-footer
+		button.btn.btn-default(
+			ng-disabled="inflight"
+			ng-click="cancel()"
+		) #{translate("cancel")}
+		button.btn.btn-danger(
+			ng-disabled="state.inflight"
+			ng-click="confirmLeaveGroup()"
+		)
+			span(ng-hide="inflight") #{translate("leave_now")}
+			span(ng-show="inflight") #{translate("processing")}...

+ 13 - 0
services/web/app/views/subscriptions/dashboard/_personal_subscription.pug

@@ -0,0 +1,13 @@
+if (personalSubscription.recurly)
+	include ./_personal_subscription_recurly
+else
+	include ./_personal_subscription_custom
+
+if(personalSubscription.groupPlan)
+	hr
+	p
+		| You are the manager of a group subscription
+	p
+		a(href="/subscription/group").btn.btn-success !{translate("manage_group")}
+
+hr

+ 1 - 0
services/web/app/views/subscriptions/dashboard/_personal_subscription_custom.pug

@@ -0,0 +1 @@
+p To make changes to your subscription please contact accounts@sharelatex.com

+ 93 - 0
services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug

@@ -0,0 +1,93 @@
+script(src="https://js.recurly.com/v3/recurly.js")
+script(type='text/javascript').
+	window.recurlyApiKey = "!{settings.apis.recurly.publicKey}"
+	window.subscription = !{JSON.stringify(personalSubscription)}
+	window.recomendedCurrency = "#{personalSubscription.recurly.currency}"
+
+div(ng-controller="RecurlySubscriptionController")
+	div(ng-show="!showCancellation")
+		case personalSubscription.recurly.state
+			when "active"
+				p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + personalSubscription.plan.name + "</strong>"})} 
+					a(href, ng-click="switchToChangePlanView()", ng-if="showChangePlanButton") !{translate("change_plan")}.
+				-if (personalSubscription.recurly.trialEndsAtFormatted)
+					p You're on a free trial which ends on <strong>#{personalSubscription.recurly.trialEndsAtFormatted}</strong>
+				p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>" + personalSubscription.recurly.price + "</strong>", collectionDate:"<strong>" + personalSubscription.recurly.nextPaymentDueAt + "</strong>"})}
+				p.pull-right
+				p
+					a(href=personalSubscription.recurly.billingDetailsLink, target="_blank").btn.btn-info #{translate("update_your_billing_details")}
+					| &nbsp;
+					a(href, ng-click="switchToCancellationView()").btn.btn-primary !{translate("cancel_your_subscription")}
+			when "canceled"
+				p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + personalSubscription.plan.name + "</strong>"})}
+				p !{translate("subscription_canceled_and_terminate_on_x", {terminateDate:"<strong>" + personalSubscription.recurly.nextPaymentDueAt + "</strong>"})}
+				p: form(action="/user/subscription/reactivate",method="post")
+					input(type="hidden", name="_csrf", value=csrfToken)
+					input(type="submit",value="Reactivate your subscription").btn.btn-success
+			when "expired"
+				p !{translate("your_subscription_has_expired")}	
+					a(href="/user/subscription/plans") !{translate("create_new_subscription")}
+			default
+				p !{translate("problem_with_subscription_contact_us")}
+
+	include ./_change_plans_mixins
+	div(ng-show="showChangePlan", ng-cloak)
+		h2 !{translate("change_plan")}
+		p: table.table
+			tr
+				th !{translate("name")}
+				th !{translate("price")}
+				th
+			+printPlans(plans.studentAccounts)
+			+printPlans(plans.individualMonthlyPlans)
+			+printPlans(plans.individualAnnualPlans)
+
+
+	.div(ng-controller="RecurlyCancellationController", ng-show="showCancellation").text-center
+		p
+			strong Are you sure you want to cancel?
+
+		div(ng-show="showExtendFreeTrial")
+			p !{translate("have_more_days_to_try", {days:14})}
+			p
+				button(type="submit", ng-click="extendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
+			p
+				a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
+
+		div(ng-show="showDowngradeToStudent")
+			div(ng-controller="ChangePlanFormController")
+				p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
+				p
+					button(type="submit", ng-click="downgradeToStudent()", ng-disabled='inflight').btn.btn-info #{translate("yes_please")}
+				p
+					a(href, ng-click="cancelSubscription()", ng-disabled='inflight')  #{translate("no_thanks_cancel_now")}
+
+		div(ng-show="showBasicCancel")
+			p #{translate("sure_you_want_to_cancel")}
+			p
+				a(href, ng-click="switchToDefaultView()").btn.btn-info #{translate("i_want_to_stay")}
+			p
+				a(href, ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
+
+script(type="text/javascript").
+	$('#cancelSubscription').on("click", function() {
+		ga('send', 'event', 'subscription-funnel', 'cancelation')
+	})
+
+script(type='text/ng-template', id='confirmChangePlanModalTemplate')
+	.modal-header
+		h3 #{translate("change_plan")}
+	.modal-body
+		p !{translate("sure_you_want_to_change_plan", {planName:"<strong>{{plan.name}}</strong>"})}
+	.modal-footer
+		button.btn.btn-default(
+			ng-disabled="inflight"
+			ng-click="cancel()"
+		) #{translate("cancel")}
+		button.btn.btn-success(
+			ng-disabled="state.inflight"
+			ng-click="confirmChangePlan()"
+		)
+			span(ng-hide="inflight") #{translate("change_plan")}
+			span(ng-show="inflight") #{translate("processing")}...
+

+ 16 - 0
services/web/app/views/subscriptions/dashboard/_v1_subscriptions.pug

@@ -0,0 +1,16 @@
+- if (v1Subscriptions.has_subscription)
+	-hasAnySubscription = true
+	p
+		| You are subscribed to Overleaf through Overleaf v1
+	p
+		a.btn.btn-primary(href='/sign_in_to_v1?return_to=/users/edit%23status') Manage v1 Subscription
+	hr
+
+- if (v1Subscriptions.teams && v1Subscriptions.teams.length > 0)
+	-hasAnySubscription = true
+	for team in v1Subscriptions.teams
+		p
+			| You are a member of the Overleaf v1 team: <strong>#{team.name}</strong>
+	p
+		a.btn.btn-primary(href="/sign_in_to_v1?return_to=/teams") Manage v1 Team Membership
+	hr

+ 3 - 3
services/web/app/views/subscriptions/successful_subscription.pug

@@ -2,21 +2,21 @@ extends ../layout
 
 block content
 	.content.content-alt
-		.container(ng-controller="SuccessfulSubscriptionController")
+		.container
 			.row
 				.col-md-8.col-md-offset-2
 					.card(ng-cloak)
 						.page-header
 							h2 #{translate("thanks_for_subscribing")}
 						.alert.alert-success
-							p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>"+subscription.price+"</strong>", collectionDate:"<strong>"+subscription.nextPaymentDueAt+"</strong>"})}
+							p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>"+subscription.recurly.price+"</strong>", collectionDate:"<strong>"+subscription.recurly.nextPaymentDueAt+"</strong>"})}
 							p #{translate("to_modify_your_subscription_go_to")} 
 								a(href="/user/subscription")  #{translate("manage_subscription")}.
 						p
 							- if (subscription.groupPlan == true)
 								a.btn.btn-success.btn-large(href="/subscription/group") #{translate("add_your_first_group_member_now")}
 						p.letter-from-founders
-							p #{translate("thanks_for_subscribing_you_help_sl", {planName:subscription.name})}
+							p #{translate("thanks_for_subscribing_you_help_sl", {planName:subscription.plan.name})}
 							p #{translate("need_anything_contact_us_at")} 
 								a(href='mailto:support@sharelatex.com') #{settings.adminEmail}
 								| .

+ 84 - 157
services/web/public/src/main/subscription-dashboard.js

@@ -10,47 +10,48 @@
 /*
  * decaffeinate suggestions:
  * DS102: Remove unnecessary code created because of implicit returns
- * DS103: Rewrite code to no longer use __guard__
  * DS104: Avoid inline assignments
  * DS204: Change includes calls to have a more natural evaluation order
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
 define(['base'], function(App) {
-  App.controller('SuccessfulSubscriptionController', function(
-    $scope,
-    sixpack
-  ) {})
-
   const SUBSCRIPTION_URL = '/user/subscription/update'
 
-  const setupReturly = _.once(
-    () =>
-      typeof recurly !== 'undefined' && recurly !== null
-        ? recurly.configure(window.recurlyApiKey)
-        : undefined
-  )
-  const PRICES = {}
-
-  App.controller('CurrenyDropdownController', function(
-    $scope,
-    MultiCurrencyPricing,
-    $q
-  ) {
-    // $scope.plans = MultiCurrencyPricing.plans
-    $scope.currencyCode = MultiCurrencyPricing.currencyCode
+  const ensureRecurlyIsSetup = _.once(() => {
+    if (!recurly) return
+    recurly.configure(window.recurlyApiKey)
+  })
 
-    return ($scope.changeCurrency = newCurrency =>
-      (MultiCurrencyPricing.currencyCode = newCurrency))
+  App.factory('RecurlyPricing', function($q, MultiCurrencyPricing) {
+    return {
+      loadDisplayPriceWithTax: function(planCode, currency, taxRate) {
+        ensureRecurlyIsSetup()
+        const currencySymbol = MultiCurrencyPricing.plans[currency].symbol
+        const pricing = recurly.Pricing()
+        return $q(function(resolve, reject) {
+          pricing
+            .plan(planCode, { quantity: 1 })
+            .currency(currency)
+            .done(function(price) {
+              const totalPriceExTax = parseFloat(price.next.total)
+              let taxAmmount = totalPriceExTax * taxRate
+              if (isNaN(taxAmmount)) {
+                taxAmmount = 0
+              }
+              resolve(`${currencySymbol}${totalPriceExTax + taxAmmount}`)
+            })
+        })
+      }
+    }
   })
 
   App.controller('ChangePlanFormController', function(
     $scope,
     $modal,
-    MultiCurrencyPricing
+    RecurlyPricing
   ) {
-    setupReturly()
-    const { taxRate } = window
+    ensureRecurlyIsSetup()
 
     $scope.changePlan = () =>
       $modal.open({
@@ -59,44 +60,16 @@ define(['base'], function(App) {
         scope: $scope
       })
 
-    $scope.$watch(
-      'pricing.currencyCode',
-      () => ($scope.currencyCode = MultiCurrencyPricing.currencyCode)
-    )
-
-    $scope.pricing = MultiCurrencyPricing
-    // $scope.plans = MultiCurrencyPricing.plans
-    $scope.currencySymbol =
-      MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode] != null
-        ? MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol
-        : undefined
-
-    $scope.currencyCode = MultiCurrencyPricing.currencyCode
-
-    $scope.prices = PRICES
-    return ($scope.refreshPrice = function(planCode) {
-      let price
-      if ($scope.prices[planCode] != null) {
-        return
-      }
-      $scope.prices[planCode] = '...'
-      const pricing = recurly.Pricing()
-      pricing
-        .plan(planCode, { quantity: 1 })
-        .currency(MultiCurrencyPricing.currencyCode)
-        .done(function(price) {
-          const totalPriceExTax = parseFloat(price.next.total)
-          return $scope.$evalAsync(function() {
-            let taxAmmount = totalPriceExTax * taxRate
-            if (isNaN(taxAmmount)) {
-              taxAmmount = 0
-            }
-            return ($scope.prices[planCode] =
-              $scope.currencySymbol + (totalPriceExTax + taxAmmount))
-          })
-        })
-
-      return (price = '')
+    $scope.$watch('plan', function(plan) {
+      if (!plan) return
+      const planCode = plan.planCode
+      const { currency, taxRate } = window.subscription.recurly
+      $scope.price = '...' // Placeholder while we talk to recurly
+      RecurlyPricing.loadDisplayPriceWithTax(planCode, currency, taxRate).then(
+        price => {
+          $scope.price = price
+        }
+      )
     })
   })
 
@@ -141,69 +114,52 @@ define(['base'], function(App) {
     return ($scope.cancel = () => $modalInstance.dismiss('cancel'))
   })
 
-  return App.controller('UserSubscriptionController', function(
-    $scope,
-    MultiCurrencyPricing,
-    $http,
-    sixpack,
-    $modal
-  ) {
-    $scope.plans = MultiCurrencyPricing.plans
-
-    const freeTrialEndDate = new Date(
-      typeof subscription !== 'undefined' && subscription !== null
-        ? subscription.trial_ends_at
-        : undefined
-    )
+  App.controller('GroupMembershipController', function($scope, $modal) {
+    $scope.removeSelfFromGroup = function(admin_id) {
+      $scope.admin_id = admin_id
+      return $modal.open({
+        templateUrl: 'LeaveGroupModalTemplate',
+        controller: 'LeaveGroupModalController',
+        scope: $scope
+      })
+    }
+  })
 
-    const sevenDaysTime = new Date()
-    sevenDaysTime.setDate(sevenDaysTime.getDate() + 7)
+  App.controller('RecurlySubscriptionController', function($scope) {
+    $scope.showChangePlanButton = !subscription.groupPlan
 
-    const freeTrialInFuture = freeTrialEndDate > new Date()
-    const freeTrialExpiresUnderSevenDays = freeTrialEndDate < sevenDaysTime
+    $scope.switchToDefaultView = () => {
+      $scope.showCancellation = false
+      $scope.showChangePlan = false
+    }
+    $scope.switchToDefaultView()
 
-    $scope.view = 'overview'
-    $scope.getSuffix = planCode =>
-      __guard__(
-        planCode != null ? planCode.match(/(.*?)_(.*)/) : undefined,
-        x => x[2]
-      ) || null
-    $scope.subscriptionSuffix = $scope.getSuffix(
-      __guard__(
-        typeof window !== 'undefined' && window !== null
-          ? window.subscription
-          : undefined,
-        x => x.planCode
-      )
-    )
-    if ($scope.subscriptionSuffix === 'free_trial_7_days') {
-      $scope.subscriptionSuffix = ''
+    $scope.switchToCancellationView = () => {
+      $scope.showCancellation = true
+      $scope.showChangePlan = false
     }
-    $scope.isNextGenPlan =
-      ['heron', 'ibis'].includes($scope.subscriptionSuffix) ||
-      subscription.groupPlan
 
-    $scope.shouldShowPlan = function(planCode) {
-      let needle
-      return (
-        (needle = $scope.getSuffix(planCode)),
-        !['heron', 'ibis'].includes(needle)
-      )
+    $scope.switchToChangePlanView = () => {
+      $scope.showCancellation = false
+      $scope.showChangePlan = true
     }
+  })
+
+  App.controller('RecurlyCancellationController', function(
+    $scope,
+    RecurlyPricing,
+    $http
+  ) {
+    const subscription = window.subscription
+    const sevenDaysTime = new Date()
+    sevenDaysTime.setDate(sevenDaysTime.getDate() + 7)
+    const freeTrialEndDate = new Date(subscription.recurly.trial_ends_at)
+    const freeTrialInFuture = freeTrialEndDate > new Date()
+    const freeTrialExpiresUnderSevenDays = freeTrialEndDate < sevenDaysTime
 
     const isMonthlyCollab =
-      __guard__(
-        typeof subscription !== 'undefined' && subscription !== null
-          ? subscription.planCode
-          : undefined,
-        x1 => x1.indexOf('collaborator')
-      ) !== -1 &&
-      __guard__(
-        typeof subscription !== 'undefined' && subscription !== null
-          ? subscription.planCode
-          : undefined,
-        x2 => x2.indexOf('ann')
-      ) === -1 &&
+      subscription.plan.planCode.indexOf('collaborator') !== -1 &&
+      subscription.plan.planCode.indexOf('ann') === -1 &&
       !subscription.groupPlan
     const stillInFreeTrial = freeTrialInFuture && freeTrialExpiresUnderSevenDays
 
@@ -215,25 +171,13 @@ define(['base'], function(App) {
       $scope.showBasicCancel = true
     }
 
-    setupReturly()
-
-    recurly
-      .Pricing()
-      .plan('student', { quantity: 1 })
-      .currency(MultiCurrencyPricing.currencyCode)
-      .done(function(price) {
-        const totalPriceExTax = parseFloat(price.next.total)
-        return $scope.$evalAsync(function() {
-          let taxAmmount = totalPriceExTax * taxRate
-          if (isNaN(taxAmmount)) {
-            taxAmmount = 0
-          }
-          $scope.currencySymbol =
-            MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol
-          return ($scope.studentPrice =
-            $scope.currencySymbol + (totalPriceExTax + taxAmmount))
-        })
-      })
+    const { currency, taxRate } = window.subscription.recurly
+    $scope.studentPrice = '...' // Placeholder while we talk to recurly
+    RecurlyPricing.loadDisplayPriceWithTax('student', currency, taxRate).then(
+      price => {
+        $scope.studentPrice = price
+      }
+    )
 
     $scope.downgradeToStudent = function() {
       const body = {
@@ -257,30 +201,13 @@ define(['base'], function(App) {
         .catch(() => console.log('something went wrong changing plan'))
     }
 
-    $scope.removeSelfFromGroup = function(admin_id) {
-      $scope.admin_id = admin_id
-      return $modal.open({
-        templateUrl: 'LeaveGroupModalTemplate',
-        controller: 'LeaveGroupModalController',
-        scope: $scope
-      })
-    }
-
-    $scope.switchToCancelationView = () => ($scope.view = 'cancelation')
-
-    return ($scope.exendTrial = function() {
+    $scope.extendTrial = function() {
       const body = { _csrf: window.csrfToken }
       $scope.inflight = true
       return $http
         .put('/user/subscription/extend', body)
         .then(() => location.reload())
         .catch(() => console.log('something went wrong changing plan'))
-    })
+    }
   })
 })
-
-function __guard__(value, transform) {
-  return typeof value !== 'undefined' && value !== null
-    ? transform(value)
-    : undefined
-}

+ 191 - 0
services/web/test/acceptance/coffee/SubscriptionTests.coffee

@@ -0,0 +1,191 @@
+expect = require('chai').expect
+async = require("async")
+User = require "./helpers/User"
+{Subscription} = require "../../../app/js/models/Subscription"
+SubscriptionViewModelBuilder = require "../../../app/js/Features/Subscription/SubscriptionViewModelBuilder"
+
+MockRecurlyApi = require "./helpers/MockRecurlyApi"
+MockV1Api = require "./helpers/MockV1Api"
+
+describe 'Subscriptions', ->
+	describe 'dashboard', ->
+		before (done) ->
+			@user = new User()
+			@user.ensureUserExists done
+
+		describe 'when the user has no subscription', ->
+			before (done) ->
+				SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel @user, (error, @data) =>
+					return done(error) if error?
+					done()
+
+			it 'should return no personalSubscription', ->
+				expect(@data.personalSubscription).to.equal null
+
+			it 'should return no groupSubscriptions', ->
+				expect(@data.groupSubscriptions).to.deep.equal []
+
+			it 'should return no v1Subscriptions', ->
+				expect(@data.v1Subscriptions).to.deep.equal {}
+
+		describe 'when the user has a subscription with recurly', ->
+			before (done) ->
+				MockRecurlyApi.accounts['mock-account-id'] = @accounts = {
+					hosted_login_token: 'mock-login-token'
+				}
+				MockRecurlyApi.subscriptions['mock-subscription-id'] = @subscription = {
+					plan_code: 'collaborator',
+					tax_in_cents: 100,
+					tax_rate: 0.2,
+					unit_amount_in_cents: 500,
+					currency: 'GBP',
+					current_period_ends_at: new Date(2018,4,5),
+					state: 'active',
+					account_id: 'mock-account-id',
+					trial_ends_at: new Date(2018, 6, 7)
+				}
+				Subscription.create {
+					admin_id: @user._id,
+					manager_ids: [@user._id],
+					recurlySubscription_id: 'mock-subscription-id',
+					planCode: 'collaborator'
+				}, (error) =>
+					return done(error) if error?
+					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel @user, (error, @data) =>
+						return done(error) if error?
+						done()
+				return
+
+			after (done) ->
+				MockRecurlyApi.accounts = {}
+				MockRecurlyApi.subscriptions = {}
+				Subscription.remove {
+					admin_id: @user._id
+				}, done
+				return
+
+			it 'should return a personalSubscription with populated recurly data', ->
+				subscription = @data.personalSubscription
+				expect(subscription).to.exist
+				expect(subscription.planCode).to.equal 'collaborator'
+				expect(subscription.recurly).to.exist
+				expect(subscription.recurly).to.deep.equal {
+					"billingDetailsLink": "https://test.recurly.com/account/billing_info/edit?ht=mock-login-token"
+					"currency": "GBP"
+					"nextPaymentDueAt": "5th May 2018"
+					"price": "£6.00"
+					"state": "active"
+					"tax": 100
+					"taxRate": 0.2
+					"trial_ends_at": new Date(2018, 6, 7),
+					"trialEndsAtFormatted": "7th July 2018"
+				}
+
+			it 'should return no groupSubscriptions', ->
+				expect(@data.groupSubscriptions).to.deep.equal []
+
+		describe 'when the user has a subscription without recurly', ->
+			before (done) ->
+				Subscription.create {
+					admin_id: @user._id,
+					manager_ids: [@user._id],
+					planCode: 'collaborator'
+				}, (error) =>
+					return done(error) if error?
+					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel @user, (error, @data) =>
+						return done(error) if error?
+						done()
+				return
+
+			after (done) ->
+				Subscription.remove {
+					admin_id: @user._id
+				}, done
+				return
+
+			it 'should return a personalSubscription with no recurly data', ->
+				subscription = @data.personalSubscription
+				expect(subscription).to.exist
+				expect(subscription.planCode).to.equal 'collaborator'
+				expect(subscription.recurly).to.not.exist
+
+			it 'should return no groupSubscriptions', ->
+				expect(@data.groupSubscriptions).to.deep.equal []
+
+		describe 'when the user is a member of a group subscription', ->
+			before (done) ->
+				@owner1 = new User()
+				@owner2 = new User()
+				async.series [
+					(cb) => @owner1.ensureUserExists cb
+					(cb) => @owner2.ensureUserExists cb
+					(cb) => Subscription.create {
+							admin_id: @owner1._id,
+							manager_ids: [@owner1._id],
+							planCode: 'collaborator',
+							groupPlan: true,
+							member_ids: [@user._id]
+						}, cb
+					(cb) => Subscription.create {
+							admin_id: @owner2._id,
+							manager_ids: [@owner2._id],
+							planCode: 'collaborator',
+							groupPlan: true,
+							member_ids: [@user._id]
+						}, cb
+				], (error) =>				
+					return done(error) if error?
+					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel @user, (error, @data) =>
+						return done(error) if error?
+						done()
+				return
+
+			after (done) ->
+				Subscription.remove {
+					admin_id: @owner1._id
+				}, (error) =>
+					return done(error) if error?
+					Subscription.remove {
+						admin_id: @owner2._id
+					}, done
+				return
+
+			it 'should return no personalSubscription', ->
+				expect(@data.personalSubscription).to.equal null
+
+			it 'should return the two groupSubscriptions', ->
+				expect(@data.groupSubscriptions.length).to.equal 2
+				expect(
+					# Mongoose populates the admin_id with the user
+					@data.groupSubscriptions[0].admin_id._id.toString()
+				).to.equal @owner1._id
+				expect(
+					@data.groupSubscriptions[1].admin_id._id.toString()
+				).to.equal @owner2._id
+
+		describe 'when the user has a v1 subscription', ->
+			before (done) ->
+				MockV1Api.setUser v1Id = MockV1Api.nextV1Id(), {
+					subscription: @subscription = {
+						trial: false,
+						has_plan: true,
+						teams: [{
+							id: 56,
+							name: 'Test team'
+						}]
+					}
+				}
+				@user.setV1Id v1Id, (error) =>
+					return done(error) if error?
+					SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel @user, (error, @data) =>
+						return done(error) if error?
+						done()
+
+			it 'should return no personalSubscription', ->
+				expect(@data.personalSubscription).to.equal null
+
+			it 'should return no groupSubscriptions', ->
+				expect(@data.groupSubscriptions).to.deep.equal []
+
+			it 'should return a v1Subscriptions', ->
+				expect(@data.v1Subscriptions).to.deep.equal @subscription

+ 46 - 0
services/web/test/acceptance/coffee/helpers/MockRecurlyApi.coffee

@@ -0,0 +1,46 @@
+express = require("express")
+app = express()
+bodyParser = require('body-parser')
+
+app.use(bodyParser.json())
+
+module.exports = MockRecurlyApi =
+	subscriptions: {}
+
+	accounts: {}
+
+	run: () ->
+		app.get '/subscriptions/:id', (req, res, next) =>
+			subscription = @subscriptions[req.params.id]
+			if !subscription?
+				res.status(404).end()
+			else
+				res.send """
+				<subscription>
+					<plan_code>#{subscription.plan_code}</plan_code>
+					<currency>#{subscription.currency}</currency>
+					<state>#{subscription.state}</state>
+					<tax_in_cents type="integer">#{subscription.tax_in_cents}</tax_in_cents>
+					<tax_rate type="float">#{subscription.tax_rate}</tax_rate>
+					<current_period_ends_at type="datetime">#{subscription.current_period_ends_at}</current_period_ends_at>
+					<unit_amount_in_cents type="integer">#{subscription.unit_amount_in_cents}</unit_amount_in_cents>
+					<account href="accounts/#{subscription.account_id}" />
+					<trial_ends_at type="datetime">#{subscription.trial_ends_at}</trial_ends_at>
+				</subscription>
+				"""
+
+		app.get '/accounts/:id', (req, res, next) =>
+			account = @accounts[req.params.id]
+			if !account?
+				res.status(404).end()
+			else
+				res.send """
+				<account>
+					<hosted_login_token>#{account.hosted_login_token}</hosted_login_token>
+				</account>
+				"""
+
+		app.listen 6034, (error) ->
+			throw error if error?
+
+MockRecurlyApi.run()

+ 12 - 0
services/web/test/acceptance/coffee/helpers/MockV1Api.coffee

@@ -5,7 +5,11 @@ sinon = require 'sinon'
 
 app.use(bodyParser.json())
 
+v1Id = 1000
+
 module.exports = MockV1Api =
+	nextV1Id: -> v1Id++
+
 	users: { }
 
 	setUser: (id, user) ->
@@ -42,6 +46,14 @@ module.exports = MockV1Api =
 			else
 				res.sendStatus 404
 
+		app.get "/api/v1/sharelatex/users/:v1_user_id/subscriptions", (req, res, next) =>
+			user = @users[req.params.v1_user_id]
+			if user?.subscription?
+				res.json user.subscription
+			else
+				res.sendStatus 404
+
+
 		app.post "/api/v1/sharelatex/users/:v1_user_id/sync", (req, res, next) =>
 			@syncUserFeatures(req.params.v1_user_id)
 			res.sendStatus 200

+ 8 - 0
services/web/test/acceptance/coffee/helpers/User.coffee

@@ -318,4 +318,12 @@ class User
 			else
 				return callback(new Error("unexpected status code from /user/personal_info: #{response.statusCode}"))
 
+	setV1Id: (v1Id, callback) ->
+		UserModel.update {
+			_id: @_id
+		}, {
+			overleaf:
+				id: v1Id
+		}, callback
+
 module.exports = User

+ 6 - 0
services/web/test/acceptance/config/settings.test.coffee

@@ -4,6 +4,12 @@ v1Api =
 module.exports =
 	enableSubscriptions: true
 
+	apis:
+		recurly:
+			# Set up our own mock recurly server
+			url: 'http://localhost:6034'
+			subdomain: 'test'
+
 	# for registration via SL, set enableLegacyRegistration to true
 	# for registration via Overleaf v1, set enableLegacyLogin to true
 

+ 20 - 83
services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee

@@ -1,6 +1,7 @@
 SandboxedModule = require('sandboxed-module')
 sinon = require 'sinon'
 should = require("chai").should()
+expect = require("chai").expect
 MockRequest = require "../helpers/MockRequest"
 MockResponse = require "../helpers/MockResponse"
 modulePath = '../../../../app/js/Features/Subscription/SubscriptionController'
@@ -44,7 +45,7 @@ describe "SubscriptionController", ->
 			userHasV2Subscription: sinon.stub()
 
 		@SubscriptionViewModelBuilder =
-			buildUsersSubscriptionViewModel:sinon.stub().callsArgWith(1, null, @activeRecurlySubscription)
+			buildUsersSubscriptionViewModel:sinon.stub().callsArgWith(1, null, {})
 			buildViewModel: sinon.stub()
 		@settings =
 			coupon_codes:
@@ -226,92 +227,28 @@ describe "SubscriptionController", ->
 			@SubscriptionController.successful_subscription @req, @res
 
 	describe "userSubscriptionPage", ->
-		describe "with a user without a subscription", ->
-			beforeEach (done) ->
-				@res.callback = done
-				@LimitationsManager.hasPaidSubscription.callsArgWith(1, null, false)
-				@SubscriptionController.userSubscriptionPage @req, @res
-
-			it "should redirect to the plans page", ->
-				@res.redirected.should.equal true
-				@res.redirectedTo.should.equal "/user/subscription/plans"
-
-		describe "with a potential domain licence", ->
-			beforeEach () ->
-				@groupUrl = "/go/over-here"
-				@SubscriptionDomainHandler.getDomainLicencePage.returns(@groupUrl)
-
-			describe "without an existing subscription", ->
-				beforeEach (done)->
-					@res.callback = done
-					@LimitationsManager.hasPaidSubscription.callsArgWith(1, null, false)
-					@SubscriptionController.userSubscriptionPage @req, @res
-
-				it "should redirect to the group invite url", ->
-					@res.redirected.should.equal true
-					@res.redirectedTo.should.equal @groupUrl
-
-			describe "with an existing subscription", ->
-				beforeEach (done)->
-					@res.callback = done
-					@settings.apis.recurly.subdomain = 'test'
-					@userSub = {account: {hosted_login_token: 'abcd'}}
-					@LimitationsManager.hasPaidSubscription
-						.callsArgWith(1, null, true, {})
-					@SubscriptionController.userSubscriptionPage @req, @res
-
-
-				it "should render the dashboard", ->
-					@res.renderedTemplate.should.equal "subscriptions/dashboard"
-
-		describe "with a user with a paid subscription", ->
-			beforeEach (done) ->
-				@res.callback = done
-				@SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel.callsArgWith(1, null, @activeRecurlySubscription)
-				@LimitationsManager.hasPaidSubscription.callsArgWith(1, null, true, {})
-				@SubscriptionController.userSubscriptionPage @req, @res
-
-			it "should render the dashboard", (done)->
-				@res.rendered.should.equal true
-				@res.renderedTemplate.should.equal "subscriptions/dashboard"
+		beforeEach (done) ->
+			@SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel.callsArgWith(1, null, {
+				personalSubscription: @personalSubscription = { 'personal-subscription': 'mock' }
+				groupSubscriptions: @groupSubscriptions = { 'personal-subscriptions': 'mock' }
+				v1Subscriptions: @v1Subscriptions = { 'v1-subscriptions': 'mock' }
+			})
+			@SubscriptionViewModelBuilder.buildViewModel.returns(@plans = {'plans': 'mock'})
+			@res.render = (view, @data) =>
+				expect(view).to.equal 'subscriptions/dashboard'
 				done()
+			@SubscriptionController.userSubscriptionPage @req, @res
 
-			it "should set the correct subscription details", ->
-				@res.renderedVariables.subscription.should.deep.equal @activeRecurlySubscription
-
-		describe "with a user with a free trial", ->
-			beforeEach (done) ->
-				@res.callback = done
-				@SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel.callsArgWith(1, null, @activeRecurlySubscription)
-				@LimitationsManager.hasPaidSubscription.callsArgWith(1, null, true, {})
-				@SubscriptionController.userSubscriptionPage @req, @res
+		it "should load the personal, groups and v1 subscriptions", ->
+			expect(@data.personalSubscription).to.deep.equal @personalSubscription
+			expect(@data.groupSubscriptions).to.deep.equal @groupSubscriptions
+			expect(@data.v1Subscriptions).to.deep.equal @v1Subscriptions
 
-			it "should render the dashboard", ->
-				@res.renderedTemplate.should.equal "subscriptions/dashboard"
-
-			it "should set the correct subscription details", ->
-				@res.renderedVariables.subscription.should.deep.equal @activeRecurlySubscription
-
-
-		describe "when its a custom subscription which is non recurly", ->
-			beforeEach ()->
-				@LimitationsManager.hasPaidSubscription.callsArgWith(1, null, true, {customAccount:true})
-				@SubscriptionController.userSubscriptionPage @req, @res
-
-			it "should redirect to /user/subscription/custom_account", ->
-				@res.redirectedTo.should.equal("/user/subscription/custom_account")
-
-	describe "userCustomSubscriptionPage", ->
-		beforeEach (done) ->
-			@res.callback = done
-			@LimitationsManager.hasPaidSubscription.callsArgWith(1, null, true, {})
-			@SubscriptionController.userCustomSubscriptionPage @req, @res
-
-		it "should render the page", (done)->
-			@res.rendered.should.equal true
-			@res.renderedTemplate.should.equal "subscriptions/custom_account"
-			done()
+		it "should load the user", ->
+			expect(@data.user).to.deep.equal @user
 
+		it "should load the plans", ->
+			expect(@data.plans).to.deep.equal @plans
 
 	describe "createSubscription", ->
 		beforeEach (done)->

+ 0 - 8
services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee

@@ -24,7 +24,6 @@ describe "SubscriptionUpdater", ->
 			manager_ids: [@adminUser._id]
 			member_ids: @allUserIds
 			save: sinon.stub().callsArgWith(0)
-			freeTrial:{}
 			planCode:"student_or_something"
 		@user_id = @adminuser_id
 
@@ -34,7 +33,6 @@ describe "SubscriptionUpdater", ->
 			manager_ids: [@adminUser._id]
 			member_ids: @allUserIds
 			save: sinon.stub().callsArgWith(0)
-			freeTrial:{}
 			planCode:"group_subscription"
 
 
@@ -54,7 +52,6 @@ describe "SubscriptionUpdater", ->
 			getGroupSubscriptionMemberOf:sinon.stub()
 
 		@Settings =
-			freeTrialPlanCode: "collaborator"
 			defaultPlanCode: "personal"
 			defaultFeatures: { "default": "features" }
 
@@ -116,10 +113,6 @@ describe "SubscriptionUpdater", ->
 			@SubscriptionUpdater._updateSubscriptionFromRecurly @recurlySubscription, @subscription, (err)=>
 				@subscription.recurlySubscription_id.should.equal @recurlySubscription.uuid
 				@subscription.planCode.should.equal @recurlySubscription.plan.plan_code
-
-				@subscription.freeTrial.allowed.should.equal true
-				assert.equal(@subscription.freeTrial.expiresAt, undefined)
-				assert.equal(@subscription.freeTrial.planCode, undefined)
 				@subscription.save.called.should.equal true
 				@FeaturesUpdater.refreshFeatures.calledWith(@adminUser._id).should.equal true
 				done()
@@ -157,7 +150,6 @@ describe "SubscriptionUpdater", ->
 			@SubscriptionUpdater._createNewSubscription @adminUser._id, =>
 				@subscription.admin_id.should.equal @adminUser._id
 				@subscription.manager_ids.should.deep.equal [@adminUser._id]
-				@subscription.freeTrial.allowed.should.equal false
 				@subscription.save.called.should.equal true
 				done()
 

+ 0 - 70
services/web/test/unit/coffee/Subscription/SubscriptionViewModelBuilderTests.coffee

@@ -1,70 +0,0 @@
-SandboxedModule = require('sandboxed-module')
-sinon = require 'sinon'
-should = require("chai").should()
-modulePath = '../../../../app/js/Features/Subscription/SubscriptionViewModelBuilder'
-
-describe 'SubscriptionViewModelBuilder', ->
-	mockSubscription =
-		uuid: "subscription-123-active"
-		plan:
-			name: "Gold"
-			plan_code: "gold"
-		current_period_ends_at: new Date()
-		state: "active"
-		unit_amount_in_cents: 999
-		account:
-			account_code: "user-123"
-
-
-	beforeEach ->
-		@user =
-			email:"tom@yahoo.com",
-			_id: 'one',
-			signUpDate: new Date('2000-10-01')
-
-		@plan =
-			name: "test plan"
-
-		@SubscriptionFormatters =
-			formatDate: sinon.stub().returns("Formatted date")
-			formatPrice: sinon.stub().returns("Formatted price")
-
-		@RecurlyWrapper =
-			sign: sinon.stub().callsArgWith(1, null, "something")
-			getSubscription: sinon.stub().callsArgWith	2, null,
-				account:
-					hosted_login_token: "hosted_login_token"
-
-		@builder = SandboxedModule.require modulePath, requires:
-			"settings-sharelatex": { apis: { recurly: { subdomain: "example.com" }}}
-			"./RecurlyWrapper": @RecurlyWrapper
-			"./PlansLocator": @PlansLocator = {}
-			"./SubscriptionLocator": @SubscriptionLocator = {}
-			"./SubscriptionFormatters": @SubscriptionFormatters
-			"./LimitationsManager": {}
-			"./V1SubscriptionManager": @V1SubscriptionManager = {}
-			"logger-sharelatex":
-				log:->
-				warn:->
-			"underscore": {}
-
-		@PlansLocator.findLocalPlanInSettings = sinon.stub().returns(@plan)
-		@SubscriptionLocator.getUsersSubscription =  sinon.stub().callsArgWith(1, null, mockSubscription)
-		@SubscriptionLocator.getMemberSubscriptions = sinon.stub().callsArgWith(1, null, null)
-		@V1SubscriptionManager.getSubscriptionsFromV1 = sinon.stub().yields(null, @mockV1Sub = ['mock-v1-subs'])
-
-	it 'builds the user view model', ->
-		callback = (error, subscription, memberSubscriptions, billingDetailsLink, v1Sub) =>
-			@error = error
-			@subscription = subscription
-			@memberSubscriptions = memberSubscriptions
-			@billingDetailsLink = billingDetailsLink
-			@v1Sub = v1Sub
-
-		@builder.buildUsersSubscriptionViewModel(@user, callback)
-
-		@subscription.name.should.eq 'test plan'
-		@subscription.nextPaymentDueAt.should.eq 'Formatted date'
-		@subscription.price.should.eq 'Formatted price'
-		@billingDetailsLink.should.eq "https://example.com.recurly.com/account/billing_info/edit?ht=hosted_login_token"
-		@v1Sub.should.deep.equal @mockV1Sub