From 140f97eb20c743471a58101b02b19b1ad8bd1940 Mon Sep 17 00:00:00 2001 From: James Allen Date: Tue, 13 Nov 2018 14:35:50 +0100 Subject: [PATCH] Merge pull request #1107 from sharelatex/ja-purchase-groups Purchase group/team accounts directly via app GitOrigin-RevId: 1a502878753de77758fb431f45a6366f199f1cb0 --- .../Subscription/GroupPlansData.coffee | 37 +++++ .../SubscriptionController.coffee | 2 + services/web/app/templates/plans/groups.json | 122 +++++++++++++++ .../subscriptions/_modal_group_inquiry.pug | 2 +- .../subscriptions/_modal_group_purchase.pug | 52 +++++++ services/web/app/views/subscriptions/new.pug | 12 +- .../web/app/views/subscriptions/plans.pug | 8 +- services/web/config/settings.defaults.coffee | 3 + .../web/public/src/main/new-subscription.js | 116 +++++---------- services/web/public/src/main/plans.js | 140 ++++++++++++++---- .../public/src/main/subscription-dashboard.js | 7 +- .../web/public/stylesheets/app/plans.less | 11 +- services/web/scripts/recurly/Gemfile | 4 + services/web/scripts/recurly/sync_recurly.rb | 62 ++++++++ .../SubscriptionControllerTests.coffee | 3 + 15 files changed, 464 insertions(+), 117 deletions(-) create mode 100644 services/web/app/coffee/Features/Subscription/GroupPlansData.coffee create mode 100644 services/web/app/templates/plans/groups.json create mode 100644 services/web/app/views/subscriptions/_modal_group_purchase.pug create mode 100644 services/web/scripts/recurly/Gemfile create mode 100644 services/web/scripts/recurly/sync_recurly.rb diff --git a/services/web/app/coffee/Features/Subscription/GroupPlansData.coffee b/services/web/app/coffee/Features/Subscription/GroupPlansData.coffee new file mode 100644 index 0000000000..f72f882039 --- /dev/null +++ b/services/web/app/coffee/Features/Subscription/GroupPlansData.coffee @@ -0,0 +1,37 @@ +Settings = require 'settings-sharelatex' +fs = require('fs') + +# The groups.json file encodes the various group plan options we provide, and +# is used in the app the render the appropriate dialog in the plans page, and +# to generate the appropriate entries in the Settings.plans array. +# It is also used by scripts/recurly/sync_recurly.rb, which will make sure +# Recurly has a plan configured for all the groups, and that the prices are +# up to date with the data in groups.json. +data = fs.readFileSync(__dirname + '/../../../templates/plans/groups.json') +groups = JSON.parse(data.toString()) + +capitalize = (string) -> + string.charAt(0).toUpperCase() + string.slice(1); + +# With group accounts in Recurly, we end up with a lot of plans to manage. +# Rather than hand coding them in the settings file, and then needing to keep +# that data in sync with the data in groups.json, we can auto generate the +# group plan entries and append them to Settings.plans at boot time. This is not +# a particularly clean pattern, since it's a little surprising that settings +# are modified at boot-time, but I think it's a better option than trying to +# keep two sources of data in sync. +for usage, plan_data of groups + for plan_code, currency_data of plan_data + for currency, price_data of currency_data + for size, price of price_data + Settings.plans.push { + planCode: "group_#{plan_code}_#{size}_#{usage}", + name: "#{Settings.appName} #{capitalize(plan_code)} - Group Account (#{size} licenses) - #{capitalize(usage)}", + hideFromUsers: true, + annual: true + features: Settings.features[plan_code] + groupPlan: true + membersLimit: parseInt(size) + } + +module.exports = groups diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee index 0b8b5608f6..1d35b12df9 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee @@ -11,6 +11,7 @@ SubscriptionDomainHandler = require("./SubscriptionDomainHandler") UserGetter = require "../User/UserGetter" FeaturesUpdater = require './FeaturesUpdater' planFeatures = require './planFeatures' +GroupPlansData = require './GroupPlansData' module.exports = SubscriptionController = @@ -31,6 +32,7 @@ module.exports = SubscriptionController = gaExperiments: Settings.gaExperiments.plansPage recomendedCurrency:recomendedCurrency planFeatures: planFeatures + groupPlans: GroupPlansData user_id = AuthenticationController.getLoggedInUserId(req) if user_id? UserGetter.getUser user_id, {signUpDate: 1}, (err, user) -> diff --git a/services/web/app/templates/plans/groups.json b/services/web/app/templates/plans/groups.json new file mode 100644 index 0000000000..f7556afd0e --- /dev/null +++ b/services/web/app/templates/plans/groups.json @@ -0,0 +1,122 @@ +{ + "enterprise": { + "collaborator": { + "USD": { + "2": 252, + "3": 376, + "4": 495, + "5": 615, + "10": 1170, + "20": 2160, + "50": 4950 + }, + "EUR": { + "2": 235, + "3": 352, + "4": 468, + "5": 584, + "10": 1090, + "20": 2015, + "50": 4620 + }, + "GBP": { + "2": 198, + "3": 296, + "4": 394, + "5": 492, + "10": 935, + "20": 1730, + "50": 3960 + } + }, + "professional": { + "USD": { + "2": 504, + "3": 752, + "4": 990, + "5": 1230, + "10": 2340, + "20": 4320, + "50": 9900 + }, + "EUR": { + "2": 470, + "3": 704, + "4": 936, + "5": 1168, + "10": 2185, + "20": 4030, + "50": 9240 + }, + "GBP": { + "2": 396, + "3": 592, + "4": 788, + "5": 984, + "10": 1870, + "20": 3455, + "50": 7920 + } + } + }, + "educational": { + "collaborator": { + "USD": { + "2": 252, + "3": 376, + "4": 495, + "5": 615, + "10": 695, + "20": 1295, + "50": 2970 + }, + "EUR": { + "2": 235, + "3": 352, + "4": 468, + "5": 584, + "10": 655, + "20": 1210, + "50": 2770 + }, + "GBP": { + "2": 198, + "3": 296, + "4": 394, + "5": 492, + "10": 560, + "20": 1035, + "50": 2375 + } + }, + "professional": { + "USD": { + "2": 504, + "3": 752, + "4": 990, + "5": 1230, + "10": 1390, + "20": 2590, + "50": 5940 + }, + "EUR": { + "2": 470, + "3": 704, + "4": 936, + "5": 1168, + "10": 1310, + "20": 2420, + "50": 5545 + }, + "GBP": { + "2": 396, + "3": 592, + "4": 788, + "5": 984, + "10": 1125, + "20": 2075, + "50": 4750 + } + } + } +} \ No newline at end of file diff --git a/services/web/app/views/subscriptions/_modal_group_inquiry.pug b/services/web/app/views/subscriptions/_modal_group_inquiry.pug index cc9de0d7df..ff7608908e 100644 --- a/services/web/app/views/subscriptions/_modal_group_inquiry.pug +++ b/services/web/app/views/subscriptions/_modal_group_inquiry.pug @@ -1,4 +1,4 @@ -script(type="text/ng-template", id="groupPlanModalTemplate") +script(type="text/ng-template", id="groupPlanModalInquiryTemplate") .modal-header h3 #{translate("group_plan_enquiry")} .modal-body diff --git a/services/web/app/views/subscriptions/_modal_group_purchase.pug b/services/web/app/views/subscriptions/_modal_group_purchase.pug new file mode 100644 index 0000000000..3577146a82 --- /dev/null +++ b/services/web/app/views/subscriptions/_modal_group_purchase.pug @@ -0,0 +1,52 @@ +script(type="text/ng-template", id="groupPlanModalPurchaseTemplate") + .modal-header + h3 Save 30% or more with a group license + .modal-body.plans + .container-fluid + .row + .col-md-6.text-center + .circle.circle-lg + | {{ displayPrice }} + span.small / year + br + span.circle-subtext For {{ selected.size }} users + ul.list-unstyled + li Each user will have access to: + li   + li(ng-if="selected.plan_code == 'collaborator'") + strong #{translate("collabs_per_proj", {collabcount:10})} + li(ng-if="selected.plan_code == 'professional'") + strong #{translate("unlimited_collabs")} + +features_premium + .col-md-6 + form.form + .form-group + label(for='plan_code') + | Plan + select.form-control(id="plan_code", ng-model="selected.plan_code") + option(ng-repeat="plan_code in options.plan_codes", value="{{plan_code.code}}") {{ plan_code.display }} + .form-group + label(for='size') + | Number of users + select.form-control(id="size", ng-model="selected.size") + option(ng-repeat="size in options.sizes", value="{{size}}") {{ size }} + .form-group + label(for='currency') + | Currency + select.form-control(id="currency", ng-model="selected.currency") + option(ng-repeat="currency in options.currencies", value="{{currency.code}}") {{ currency.display }} + .form-group + label(for='usage') + | Usage + select.form-control(id="usage", ng-model="selected.usage") + option(ng-repeat="usage in options.usages", value="{{usage.code}}") {{ usage.display }} + p.small.text-center.row-spaced-small(ng-show="selected.usage == 'educational'") + | Save an additional 40% on groups of 10 or more with our educational discount + .modal-footer + .text-center + button.btn.btn-primary.btn-lg(ng-click="purchase()") Purchase Now + br + | or + br + a(href, ng-click="payByInvoice()") Pay by invoice or VAT invoice + diff --git a/services/web/app/views/subscriptions/new.pug b/services/web/app/views/subscriptions/new.pug index b23ad6e186..b71937aae2 100644 --- a/services/web/app/views/subscriptions/new.pug +++ b/services/web/app/views/subscriptions/new.pug @@ -26,10 +26,10 @@ block content data-toggle="dropdown", dropdown-toggle ) - | {{currencyCode}} ({{plans[currencyCode]['symbol']}}) + | {{currencyCode}} ({{allCurrencies[currencyCode]['symbol']}}) span.caret ul.dropdown-menu(role="menu") - li(ng-repeat="(currency, value) in plans") + li(ng-repeat="(currency, value) in availableCurrencies") a( ng-click="changeCurrency(currency)", ) {{currency}} ({{value['symbol']}}) @@ -44,11 +44,11 @@ block content span !{translate("first_few_days_free", {trialLen:'{{trialLength}}'})} span(ng-if="discountMonths && discountRate")   - {{discountMonths}} #{translate("month")}s {{discountRate}}% Off div(ng-if="price") - strong {{plans[currencyCode]['symbol']}}{{price.next.total}} + strong {{availableCurrencies[currencyCode]['symbol']}}{{price.next.total}} span(ng-if="monthlyBilling") #{translate("every")} #{translate("month")} span(ng-if="!monthlyBilling") #{translate("every")} #{translate("year")} div(ng-if="normalPrice") - span.small Normally {{plans[currencyCode]['symbol']}}{{normalPrice}} + span.small Normally {{availableCurrencies[currencyCode]['symbol']}}{{normalPrice}} .row div() .col-md-12() @@ -188,8 +188,8 @@ block content div.price-breakdown(ng-if="price.next.tax !== '0.00'") hr.thin span Total: - strong {{plans[currencyCode]['symbol']}}{{price.next.total}} - span ({{plans[currencyCode]['symbol']}}{{price.next.subtotal}} + {{plans[currencyCode]['symbol']}}{{price.next.tax}} tax) + strong {{availableCurrencies[currencyCode]['symbol']}}{{price.next.total}} + span ({{availableCurrencies[currencyCode]['symbol']}}{{price.next.subtotal}} + {{availableCurrencies[currencyCode]['symbol']}}{{price.next.tax}} tax) span(ng-if="monthlyBilling") #{translate("every")} #{translate("month")} span(ng-if="!monthlyBilling") #{translate("every")} #{translate("year")} hr.thin diff --git a/services/web/app/views/subscriptions/plans.pug b/services/web/app/views/subscriptions/plans.pug index 6cf6b51a9d..4d2bd9b517 100644 --- a/services/web/app/views/subscriptions/plans.pug +++ b/services/web/app/views/subscriptions/plans.pug @@ -8,8 +8,9 @@ block vars block scripts script(type='text/javascript'). - window.recomendedCurrency = '#{recomendedCurrency}' - window.abCurrencyFlag = '#{abCurrencyFlag}' + window.recomendedCurrency = '#{recomendedCurrency}'; + window.abCurrencyFlag = '#{abCurrencyFlag}'; + window.groupPlans = !{JSON.stringify(groupPlans)}; block content .content.content-alt.content-page @@ -94,7 +95,7 @@ block content br br a.btn.btn-default( - href + href="#groups" ng-click="openGroupPlanModal()" ) #{translate('find_out_more')} @@ -131,3 +132,4 @@ block content .row.row-spaced include _modal_group_inquiry + include _modal_group_purchase diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 5136ef19f2..44532ad6e7 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -220,6 +220,9 @@ module.exports = settings = templates: true trackChanges: true + features: + personal: defaultFeatures + plans: plans = [{ planCode: "personal" name: "Personal" diff --git a/services/web/public/src/main/new-subscription.js b/services/web/public/src/main/new-subscription.js index 8a530b9fe6..10d994a7ab 100644 --- a/services/web/public/src/main/new-subscription.js +++ b/services/web/public/src/main/new-subscription.js @@ -1,19 +1,9 @@ /* eslint-disable camelcase, max-len, - no-return-assign, - no-undef, - no-unused-vars, + no-return-assign */ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS103: Rewrite code to no longer use __guard__ - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ +/* global recurly,_,define */ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => App.controller('NewSubscriptionController', function( $scope, @@ -22,13 +12,13 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => event_tracking, ccUtils ) { - let inputHasError, isFormValid, setPaymentMethod if (typeof recurly === 'undefined') { throw new Error('Recurly API Library Missing.') } $scope.currencyCode = MultiCurrencyPricing.currencyCode - $scope.plans = MultiCurrencyPricing.plans + $scope.allCurrencies = MultiCurrencyPricing.plans + $scope.availableCurrencies = {} $scope.planCode = window.plan_code $scope.switchToStudent = function() { @@ -37,9 +27,9 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => event_tracking.sendMB('subscription-form-switch-to-student', { plan: window.plan_code }) - return (window.location = `/user/subscription/new?planCode=${planCode}¤cy=${ + window.location = `/user/subscription/new?planCode=${planCode}¤cy=${ $scope.currencyCode - }&cc=${$scope.data.coupon}`) + }&cc=${$scope.data.coupon}` } event_tracking.sendMB('subscription-form', { plan: window.plan_code }) @@ -85,7 +75,7 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => const pricing = recurly.Pricing() window.pricing = pricing - const initialPricing = pricing + pricing .plan(window.plan_code, { quantity: 1 }) .address({ country: $scope.data.country }) .tax({ tax_code: 'digital', vat_number: '' }) @@ -96,58 +86,41 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => pricing.on('change', () => { $scope.planName = pricing.items.plan.name $scope.price = pricing.price - $scope.trialLength = - pricing.items.plan.trial != null - ? pricing.items.plan.trial.length - : undefined + if (pricing.items.plan.trial) { + $scope.trialLength = pricing.items.plan.trial.length + } $scope.monthlyBilling = pricing.items.plan.period.length === 1 + $scope.availableCurrencies = {} + for (let currencyCode in pricing.items.plan.price) { + if (MultiCurrencyPricing.plans[currencyCode]) { + $scope.availableCurrencies[currencyCode] = + MultiCurrencyPricing.plans[currencyCode] + } + } + if ( - __guard__( - __guard__( - pricing.items != null ? pricing.items.coupon : undefined, - x1 => x1.discount - ), - x => x.type - ) === 'percent' + pricing.items && + pricing.items.coupon && + pricing.items.coupon.discount && + pricing.items.coupon.discount.type === 'percent' ) { const basePrice = parseInt(pricing.price.base.plan.unit) $scope.normalPrice = basePrice if ( pricing.items.coupon.applies_for_months > 0 && - __guard__( - pricing.items.coupon != null - ? pricing.items.coupon.discount - : undefined, - x2 => x2.rate - ) && - (pricing.items.coupon != null - ? pricing.items.coupon.applies_for_months - : undefined) != null + pricing.items.coupon.discount.rate && + pricing.items.coupon.applies_for_months ) { - $scope.discountMonths = - pricing.items.coupon != null - ? pricing.items.coupon.applies_for_months - : undefined - $scope.discountRate = - __guard__( - pricing.items.coupon != null - ? pricing.items.coupon.discount - : undefined, - x3 => x3.rate - ) * 100 + $scope.discountMonths = pricing.items.coupon.applies_for_months + $scope.discountRate = pricing.items.coupon.discount.rate * 100 } - if ( - __guard__( - pricing.price != null ? pricing.price.taxes[0] : undefined, - x4 => x4.rate - ) != null - ) { + if (pricing.price.taxes[0] && pricing.price.taxes[0].rate) { $scope.normalPrice += basePrice * pricing.price.taxes[0].rate } } - return $scope.$apply() + $scope.$apply() }) $scope.applyCoupon = () => pricing.coupon($scope.data.coupon).done() @@ -162,7 +135,7 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => return pricing.currency(newCurrency).done() } - $scope.inputHasError = inputHasError = function(formItem) { + $scope.inputHasError = function(formItem) { if (formItem == null) { return false } @@ -170,7 +143,7 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => return formItem.$touched && formItem.$invalid } - $scope.isFormValid = isFormValid = function(form) { + $scope.isFormValid = function(form) { if ($scope.paymentMethod.value === 'paypal') { return $scope.data.country !== '' } else { @@ -181,10 +154,10 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => $scope.updateCountry = () => pricing.address({ country: $scope.data.country }).done() - $scope.setPaymentMethod = setPaymentMethod = function(method) { + $scope.setPaymentMethod = function(method) { $scope.paymentMethod.value = method $scope.validation.errorFields = {} - return ($scope.genericError = '') + $scope.genericError = '' } const completeSubscription = function(err, recurly_token_id) { @@ -193,10 +166,10 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => event_tracking.sendMB('subscription-error', err) // We may or may not be in a digest loop here depending on // whether recurly could do validation locally, so do it async - return $scope.$evalAsync(function() { + $scope.$evalAsync(function() { $scope.processing = false $scope.genericError = err.message - return _.each( + _.each( err.fields, field => ($scope.validation.errorFields[field] = true) ) @@ -208,11 +181,8 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => subscriptionDetails: { currencyCode: pricing.items.currency, plan_code: pricing.items.plan.code, - coupon_code: - __guard__( - pricing.items != null ? pricing.items.coupon : undefined, - x => x.code - ) || '', + coupon_code: pricing.items.coupon ? pricing.items.coupon.code : '', + isPaypal: $scope.paymentMethod.value === 'paypal', address: { address1: $scope.data.address1, @@ -235,12 +205,11 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => .post('/user/subscription/create', postData) .then(function() { event_tracking.sendMB('subscription-submission-success') - return (window.location.href = '/user/subscription/thank-you') + window.location.href = '/user/subscription/thank-you' }) .catch(function() { $scope.processing = false - return ($scope.genericError = - 'Something went wrong processing the request') + $scope.genericError = 'Something went wrong processing the request' }) } } @@ -255,7 +224,7 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => } } - return ($scope.countries = [ + $scope.countries = [ { code: 'AF', name: 'Afghanistan' }, { code: 'AL', name: 'Albania' }, { code: 'DZ', name: 'Algeria' }, @@ -507,10 +476,5 @@ define(['base', 'directives/creditCards', 'libs/recurly-4.8.5'], App => { code: 'YE', name: 'Yemen' }, { code: 'ZM', name: 'Zambia' }, { code: 'AX', name: 'Åland Islandscode:' } - ]) + ] })) -function __guard__(value, transform) { - return typeof value !== 'undefined' && value !== null - ? transform(value) - : undefined -} diff --git a/services/web/public/src/main/plans.js b/services/web/public/src/main/plans.js index d87ba88ba8..6b49ddf4d1 100644 --- a/services/web/public/src/main/plans.js +++ b/services/web/public/src/main/plans.js @@ -1,16 +1,8 @@ /* eslint-disable camelcase, - max-len, - no-return-assign, - no-undef, + max-len */ -// TODO: This file was created by bulk-decaffeinate. -// Fix any style issues and re-enable lint. -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ +/* global define,history */ define(['base', 'libs/recurly-4.8.5'], function(App, recurly) { App.factory('MultiCurrencyPricing', function() { const currencyCode = window.recomendedCurrency @@ -197,14 +189,15 @@ define(['base', 'libs/recurly-4.8.5'], function(App, recurly) { } }) - return App.controller('PlansController', function( + App.controller('PlansController', function( $scope, $modal, event_tracking, MultiCurrencyPricing, $http, $filter, - ipCookie + ipCookie, + $location ) { let switchEvent $scope.showPlans = true @@ -221,7 +214,7 @@ define(['base', 'libs/recurly-4.8.5'], function(App, recurly) { $scope.changeCurreny = function(e, newCurrency) { e.preventDefault() - return ($scope.currencyCode = newCurrency) + $scope.currencyCode = newCurrency } // because ternary logic in angular bindings is hard @@ -240,48 +233,139 @@ define(['base', 'libs/recurly-4.8.5'], function(App, recurly) { } plan = eventLabel(plan, location) event_tracking.sendMB('plans-page-start-trial') - return event_tracking.send( - 'subscription-funnel', - 'sign_up_now_button', - plan - ) + event_tracking.send('subscription-funnel', 'sign_up_now_button', plan) } $scope.switchToMonthly = function(e, location) { const uiView = 'monthly' switchEvent(e, uiView + '-prices', location) - return ($scope.ui.view = uiView) + $scope.ui.view = uiView } $scope.switchToStudent = function(e, location) { const uiView = 'student' switchEvent(e, uiView + '-prices', location) - return ($scope.ui.view = uiView) + $scope.ui.view = uiView } $scope.switchToAnnual = function(e, location) { const uiView = 'annual' switchEvent(e, uiView + '-prices', location) - return ($scope.ui.view = uiView) + $scope.ui.view = uiView } $scope.openGroupPlanModal = function() { - $modal.open({ - templateUrl: 'groupPlanModalTemplate' - }) - return event_tracking.send( + history.replaceState( + null, + document.title, + window.location.pathname + '#groups' + ) + $modal + .open({ + templateUrl: 'groupPlanModalPurchaseTemplate', + controller: 'GroupPlansModalPurchaseController' + }) + .result.finally(() => + history.replaceState(null, document.title, window.location.pathname) + ) + event_tracking.send( 'subscription-funnel', 'plans-page', 'group-inquiry-potential' ) } + if ($location.hash() === 'groups') { + $scope.openGroupPlanModal() + } var eventLabel = (label, location) => label - return (switchEvent = function(e, label, location) { + switchEvent = function(e, label, location) { e.preventDefault() const gaLabel = eventLabel(label, location) - return event_tracking.send('subscription-funnel', 'plans-page', gaLabel) - }) + event_tracking.send('subscription-funnel', 'plans-page', gaLabel) + } + }) + + App.controller('GroupPlansModalPurchaseController', function($scope, $modal) { + $scope.options = { + plan_codes: [ + { + display: 'Collaborator', + code: 'collaborator' + }, + { + display: 'Professional', + code: 'professional' + } + ], + currencies: [ + { + display: 'USD ($)', + code: 'USD' + }, + { + display: 'GBP (£)', + code: 'GBP' + }, + { + display: 'EUR (€)', + code: 'EUR' + } + ], + currencySymbols: { + USD: '$', + EUR: '€', + GBP: '£' + }, + sizes: [2, 3, 4, 5, 10, 20, 50], + usages: [ + { + display: 'Enterprise', + code: 'enterprise' + }, + { + display: 'Educational', + code: 'educational' + } + ] + } + + $scope.prices = window.groupPlans + + let currency = 'USD' + if (['USD', 'GBP', 'EUR'].includes(window.recomendedCurrency)) { + currency = window.recomendedCurrency + } + + $scope.selected = { + plan_code: 'collaborator', + currency, + size: '10', + usage: 'educational' + } + + $scope.recalculatePrice = function() { + let { usage, plan_code, currency, size } = $scope.selected + const price = $scope.prices[usage][plan_code][currency][size] + const currencySymbol = $scope.options.currencySymbols[currency] + $scope.displayPrice = `${currencySymbol}${price}` + } + + $scope.$watch('selected', $scope.recalculatePrice, true) + $scope.recalculatePrice() + + $scope.purchase = function() { + let { plan_code, size, usage, currency } = $scope.selected + plan_code = `group_${plan_code}_${size}_${usage}` + window.location = `/user/subscription/new?planCode=${plan_code}¤cy=${currency}` + } + + $scope.payByInvoice = function() { + $modal.open({ + templateUrl: 'groupPlanModalInquiryTemplate' + }) + $scope.$close() + } }) }) diff --git a/services/web/public/src/main/subscription-dashboard.js b/services/web/public/src/main/subscription-dashboard.js index a372526ec9..37ec82fcb9 100644 --- a/services/web/public/src/main/subscription-dashboard.js +++ b/services/web/public/src/main/subscription-dashboard.js @@ -179,7 +179,9 @@ define(['base'], function(App) { if ($scope.subscriptionSuffix === 'free_trial_7_days') { $scope.subscriptionSuffix = '' } - $scope.isNextGenPlan = ['heron', 'ibis'].includes($scope.subscriptionSuffix) + $scope.isNextGenPlan = + ['heron', 'ibis'].includes($scope.subscriptionSuffix) || + subscription.groupPlan $scope.shouldShowPlan = function(planCode) { let needle @@ -201,7 +203,8 @@ define(['base'], function(App) { ? subscription.planCode : undefined, x2 => x2.indexOf('ann') - ) === -1 + ) === -1 && + !subscription.groupPlan const stillInFreeTrial = freeTrialInFuture && freeTrialExpiresUnderSevenDays if (isMonthlyCollab && stillInFreeTrial) { diff --git a/services/web/public/stylesheets/app/plans.less b/services/web/public/stylesheets/app/plans.less index 4fae853662..585dc4e826 100644 --- a/services/web/public/stylesheets/app/plans.less +++ b/services/web/public/stylesheets/app/plans.less @@ -62,7 +62,7 @@ .circle { font-size: 1.5rem; font-weight: 700; - padding: 38px 18px; + padding: 46px 18px; margin: 0 auto @line-height-computed; text-shadow: 0 -1px 1px darken(@link-color, 10%); width: 120px; @@ -71,11 +71,20 @@ background-color: @brand-secondary; color: white; white-space: nowrap; + line-height: 1; span.small { color: rgba(255, 255, 255, 0.85); font-size: @font-size-base * .8; } } + .circle-lg { + width: 150px; + height: 150px; + padding-top: 50px; + } + .circle-subtext { + font-size: 1rem; + } .circle-img { float: right; } diff --git a/services/web/scripts/recurly/Gemfile b/services/web/scripts/recurly/Gemfile new file mode 100644 index 0000000000..ada42b1ac5 --- /dev/null +++ b/services/web/scripts/recurly/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +gem 'recurly' +gem 'json' \ No newline at end of file diff --git a/services/web/scripts/recurly/sync_recurly.rb b/services/web/scripts/recurly/sync_recurly.rb new file mode 100644 index 0000000000..a4d464916a --- /dev/null +++ b/services/web/scripts/recurly/sync_recurly.rb @@ -0,0 +1,62 @@ +require 'rubygems' +require 'recurly' +require 'json' + +if ENV['RECURLY_SUBDOMAIN'] + Recurly.subdomain = ENV['RECURLY_SUBDOMAIN'] +else + print "Defaulting to sharelatex-sandbox. Set RECURLY_SUBDOMAIN environment variable to override\n" + Recurly.subdomain = "sharelatex-sandbox" +end + +if ENV['RECURLY_API_KEY'] + Recurly.api_key = ENV['RECURLY_API_KEY'] +else + print "Please set RECURLY_API_KEY environment variable\n" + exit 1 +end + +file = File.read('../../app/templates/plans/groups.json') +groups = JSON.parse(file) +# data format: groups[usage][plan_code][currency][size] = price + +PLANS = {} +groups.each do |usage, data| + data.each do |plan_code, data| + data.each do |currency, data| + data.each do |size, price| + full_plan_code = "group_#{plan_code}_#{size}_#{usage}" + plan = PLANS[full_plan_code] ||= { + plan_code: full_plan_code, + name: "Overleaf #{plan_code.capitalize} - Group Account (#{size} licenses) - #{usage.capitalize}", + unit_amount_in_cents: {}, + plan_interval_length: 12, + plan_interval_unit: 'months' + } + plan[:unit_amount_in_cents][currency] = price * 100 + end + end + end +end + +PLANS.each do |plan_code, plan| + print "Syncing #{plan_code}...\n" + print "#{plan}\n" + begin + recurly_plan = Recurly::Plan.find(plan_code) + rescue Recurly::Resource::NotFound => e + recurly_plan = nil + end + + if recurly_plan.nil? + print "No plan found, creating...\n" + Recurly::Plan.create(plan) + else + print "Existing plan found, updating...\n" + plan.each do |key, value| + recurly_plan[key] = value + recurly_plan.save + end + end + print "Done!\n" +end diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee index 085241203d..fa2a069fb2 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee @@ -77,6 +77,7 @@ describe "SubscriptionController", -> "../User/UserGetter": @UserGetter "./RecurlyWrapper": @RecurlyWrapper = {} "./FeaturesUpdater": @FeaturesUpdater = {} + "./GroupPlansData": @GroupPlansData = {} @res = new MockResponse() @@ -135,6 +136,8 @@ describe "SubscriptionController", -> "../User/UserGetter": @UserGetter "./RecurlyWrapper": @RecurlyWrapper = {} "./FeaturesUpdater": @FeaturesUpdater = {} + "./GroupPlansData": @GroupPlansData + @SubscriptionController.plansPage(@req, @res) it 'should not fetch the current user', (done) ->