diff --git a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee index fddd4f3567..774707a2ea 100644 --- a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee +++ b/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'] diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee index 1d35b12df9..17447ba2e4 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee +++ b/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) diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionFormatters.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionFormatters.coffee index c4af40356d..f0929a08cf 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionFormatters.coffee +++ b/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" \ No newline at end of file diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee index e8c9b916a2..d81b5f3f59 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee +++ b/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 diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee index 0e5530856c..f4cdfcf86d 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee +++ b/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? diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee index eee2c33f6b..2293e5c057 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee +++ b/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? + {personalSubscription, groupSubscriptions, v1Subscriptions, recurlySubscription, plan} = results + groupSubscriptions ?= [] + v1Subscriptions ?= {} - SubscriptionLocator.getMemberSubscriptions user, (err, memberSubscriptions = []) -> - return callback(err) if err? + if personalSubscription?.toObject? + # Downgrade from Mongoose object, so we can add a recurly and plan attribute + personalSubscription = personalSubscription.toObject() - V1SubscriptionManager.getSubscriptionsFromV1 user._id, (err, v1Subscriptions) -> - return callback(err) if err? + if plan? + personalSubscription.plan = plan - if subscription? - return callback(error) if error? + 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 + } - 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 + callback null, { + personalSubscription, groupSubscriptions, v1Subscriptions + } buildViewModel : -> plans = Settings.plans diff --git a/services/web/app/coffee/models/Subscription.coffee b/services/web/app/coffee/models/Subscription.coffee index ac3043e3f6..d094f88971 100644 --- a/services/web/app/coffee/models/Subscription.coffee +++ b/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 diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index b08b310135..fcfb06742b 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/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 diff --git a/services/web/app/views/subscriptions/custom_account.pug b/services/web/app/views/subscriptions/custom_account.pug deleted file mode 100644 index 483517d873..0000000000 --- a/services/web/app/views/subscriptions/custom_account.pug +++ /dev/null @@ -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")} diff --git a/services/web/app/views/subscriptions/dashboard.pug b/services/web/app/views/subscriptions/dashboard.pug index 816b5c32ba..07981e5de0 100644 --- a/services/web/app/views/subscriptions/dashboard.pug +++ b/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) + .card + .page-header 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:"" + subscription.expiresAt + ""})} - - when "active" - p !{translate("currently_subscribed_to_plan", {planName:"" + subscription.name + ""})} - span(ng-show="!isNextGenPlan") - a(href, ng-click="changePlan = true") !{translate("change_plan")}. - p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"" + subscription.price + "", collectionDate:"" + subscription.nextPaymentDueAt + ""})} - 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")} - |   - a(href, ng-click="switchToCancelationView()").btn.btn-primary !{translate("cancel_your_subscription")} - when "canceled" - p !{translate("currently_subscribed_to_plan", {planName:"" + subscription.name + ""})} - p !{translate("subscription_canceled_and_terminate_on_x", {terminateDate:"" + subscription.nextPaymentDueAt + ""})} - 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")} + -var hasAnySubscription = false + -if (personalSubscription) + -hasAnySubscription = true + include ./dashboard/_personal_subscription - 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 + -if (groupSubscriptions && groupSubscriptions.length > 0) + -hasAnySubscription = true + include ./dashboard/_group_memberships - each groupSubscription in groupSubscriptions - - if (user._id+'' != groupSubscription.admin_id._id+'') - div - p !{translate("member_of_group_subscription", {admin_email: "" + groupSubscription.admin_id.email + ""})} - span - button.btn.btn-danger.text-capitalise(ng-click="removeSelfFromGroup('"+groupSubscription.admin_id._id+"')") #{translate("leave_group")} - hr + -if (settings.overleaf && v1Subscriptions) + include ./dashboard/_v1_subscriptions - -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: #{team.name} - p - a.btn.btn-primary(href="/sign_in_to_v1?return_to=/teams") Manage v1 Team Membership - hr - - .card(ng-if="view == 'cancelation'") - .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 - |   - 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 - |   - 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")} - |   - a(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:"{{plan.name}}"})} - .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")}... - - - 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 diff --git a/services/web/app/views/subscriptions/dashboard/_change_plans_mixins.pug b/services/web/app/views/subscriptions/dashboard/_change_plans_mixins.pug new file mode 100644 index 0000000000..bdc75743c5 --- /dev/null +++ b/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) \ No newline at end of file diff --git a/services/web/app/views/subscriptions/dashboard/_group_memberships.pug b/services/web/app/views/subscriptions/dashboard/_group_memberships.pug new file mode 100644 index 0000000000..0410de0751 --- /dev/null +++ b/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: "" + groupSubscription.admin_id.email + ""})} + 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")}... diff --git a/services/web/app/views/subscriptions/dashboard/_personal_subscription.pug b/services/web/app/views/subscriptions/dashboard/_personal_subscription.pug new file mode 100644 index 0000000000..6c9fa58fdc --- /dev/null +++ b/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 \ No newline at end of file diff --git a/services/web/app/views/subscriptions/dashboard/_personal_subscription_custom.pug b/services/web/app/views/subscriptions/dashboard/_personal_subscription_custom.pug new file mode 100644 index 0000000000..50cd608640 --- /dev/null +++ b/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 diff --git a/services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug b/services/web/app/views/subscriptions/dashboard/_personal_subscription_recurly.pug new file mode 100644 index 0000000000..222ea8e074 --- /dev/null +++ b/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:"" + personalSubscription.plan.name + ""})} + 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 #{personalSubscription.recurly.trialEndsAtFormatted} + p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"" + personalSubscription.recurly.price + "", collectionDate:"" + personalSubscription.recurly.nextPaymentDueAt + ""})} + p.pull-right + p + a(href=personalSubscription.recurly.billingDetailsLink, target="_blank").btn.btn-info #{translate("update_your_billing_details")} + |   + a(href, ng-click="switchToCancellationView()").btn.btn-primary !{translate("cancel_your_subscription")} + when "canceled" + p !{translate("currently_subscribed_to_plan", {planName:"" + personalSubscription.plan.name + ""})} + p !{translate("subscription_canceled_and_terminate_on_x", {terminateDate:"" + personalSubscription.recurly.nextPaymentDueAt + ""})} + 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:"{{plan.name}}"})} + .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")}... + diff --git a/services/web/app/views/subscriptions/dashboard/_v1_subscriptions.pug b/services/web/app/views/subscriptions/dashboard/_v1_subscriptions.pug new file mode 100644 index 0000000000..1d0377df8c --- /dev/null +++ b/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: #{team.name} + p + a.btn.btn-primary(href="/sign_in_to_v1?return_to=/teams") Manage v1 Team Membership + hr \ No newline at end of file diff --git a/services/web/app/views/subscriptions/successful_subscription.pug b/services/web/app/views/subscriptions/successful_subscription.pug index 6fb4fc7958..cbe4786d22 100644 --- a/services/web/app/views/subscriptions/successful_subscription.pug +++ b/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:""+subscription.price+"", collectionDate:""+subscription.nextPaymentDueAt+""})} + p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:""+subscription.recurly.price+"", collectionDate:""+subscription.recurly.nextPaymentDueAt+""})} 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} | . diff --git a/services/web/public/src/main/subscription-dashboard.js b/services/web/public/src/main/subscription-dashboard.js index 37ec82fcb9..8ebee84570 100644 --- a/services/web/public/src/main/subscription-dashboard.js +++ b/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 = {} + const ensureRecurlyIsSetup = _.once(() => { + if (!recurly) return + recurly.configure(window.recurlyApiKey) + }) - App.controller('CurrenyDropdownController', function( - $scope, - MultiCurrencyPricing, - $q - ) { - // $scope.plans = MultiCurrencyPricing.plans - $scope.currencyCode = MultiCurrencyPricing.currencyCode - - 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( + 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 + }) + } + }) + + App.controller('RecurlySubscriptionController', function($scope) { + $scope.showChangePlanButton = !subscription.groupPlan + + $scope.switchToDefaultView = () => { + $scope.showCancellation = false + $scope.showChangePlan = false + } + $scope.switchToDefaultView() + + $scope.switchToCancellationView = () => { + $scope.showCancellation = true + $scope.showChangePlan = false + } + + $scope.switchToChangePlanView = () => { + $scope.showCancellation = false + $scope.showChangePlan = true + } + }) + + App.controller('RecurlyCancellationController', function( $scope, - MultiCurrencyPricing, - $http, - sixpack, - $modal + RecurlyPricing, + $http ) { - $scope.plans = MultiCurrencyPricing.plans - - const freeTrialEndDate = new Date( - typeof subscription !== 'undefined' && subscription !== null - ? subscription.trial_ends_at - : undefined - ) - + 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 - $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.isNextGenPlan = - ['heron', 'ibis'].includes($scope.subscriptionSuffix) || - subscription.groupPlan - - $scope.shouldShowPlan = function(planCode) { - let needle - return ( - (needle = $scope.getSuffix(planCode)), - !['heron', 'ibis'].includes(needle) - ) - } - 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 -} diff --git a/services/web/test/acceptance/coffee/SubscriptionTests.coffee b/services/web/test/acceptance/coffee/SubscriptionTests.coffee new file mode 100644 index 0000000000..5b95edef6e --- /dev/null +++ b/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 \ No newline at end of file diff --git a/services/web/test/acceptance/coffee/helpers/MockRecurlyApi.coffee b/services/web/test/acceptance/coffee/helpers/MockRecurlyApi.coffee new file mode 100644 index 0000000000..59ed6ee362 --- /dev/null +++ b/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.currency} + #{subscription.state} + #{subscription.tax_in_cents} + #{subscription.tax_rate} + #{subscription.current_period_ends_at} + #{subscription.unit_amount_in_cents} + + #{subscription.trial_ends_at} + + """ + + 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} + + """ + + app.listen 6034, (error) -> + throw error if error? + +MockRecurlyApi.run() diff --git a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee b/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee index 82e465193f..f44c516267 100644 --- a/services/web/test/acceptance/coffee/helpers/MockV1Api.coffee +++ b/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 diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index ebd310355c..053f3fa119 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/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 diff --git a/services/web/test/acceptance/config/settings.test.coffee b/services/web/test/acceptance/config/settings.test.coffee index e22d0ebd3b..00a9168623 100644 --- a/services/web/test/acceptance/config/settings.test.coffee +++ b/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 diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee index fa2a069fb2..9fc6b842f3 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee +++ b/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" - done() - - 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 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 + @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 render the page", (done)-> - @res.rendered.should.equal true - @res.renderedTemplate.should.equal "subscriptions/custom_account" - done() + 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 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)-> diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee index 7b3acf1b78..59b45df40c 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee +++ b/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() diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionViewModelBuilderTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionViewModelBuilderTests.coffee deleted file mode 100644 index 51ed2dbebd..0000000000 --- a/services/web/test/unit/coffee/Subscription/SubscriptionViewModelBuilderTests.coffee +++ /dev/null @@ -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 \ No newline at end of file