Merge pull request #3681 from overleaf/em-group-plans

Configure group plans for additional licenses

GitOrigin-RevId: 57822de9f490505c4b083afa80220e4d5b4c7d23
This commit is contained in:
Eric Mc Sween 2021-02-22 11:36:18 -05:00 committed by Copybot
parent 2138bd2a80
commit e5c49ea19a
5 changed files with 72 additions and 120 deletions

View file

@ -1,18 +1,6 @@
/* eslint-disable
camelcase,
max-len,
no-path-concat,
no-unused-vars,
*/
// 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
*/
const Settings = require('settings-sharelatex') const Settings = require('settings-sharelatex')
const fs = require('fs') const fs = require('fs')
const Path = require('path')
// The groups.json file encodes the various group plan options we provide, and // 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 // is used in the app the render the appropriate dialog in the plans page, and
@ -21,7 +9,7 @@ const fs = require('fs')
// Recurly has a plan configured for all the groups, and that the prices are // Recurly has a plan configured for all the groups, and that the prices are
// up to date with the data in groups.json. // up to date with the data in groups.json.
const data = fs.readFileSync( const data = fs.readFileSync(
__dirname + '/../../../templates/plans/groups.json' Path.join(__dirname, '/../../../templates/plans/groups.json')
) )
const groups = JSON.parse(data.toString()) const groups = JSON.parse(data.toString())
@ -34,27 +22,31 @@ const capitalize = string => string.charAt(0).toUpperCase() + string.slice(1)
// a particularly clean pattern, since it's a little surprising that settings // 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 // are modified at boot-time, but I think it's a better option than trying to
// keep two sources of data in sync. // keep two sources of data in sync.
for (let usage in groups) { for (const [usage, planData] of Object.entries(groups)) {
const plan_data = groups[usage] for (const [planCode, currencyData] of Object.entries(planData)) {
for (let plan_code in plan_data) { // Gather all possible sizes that are set up in at least one currency
const currency_data = plan_data[plan_code] const sizes = new Set()
for (let currency in currency_data) { for (const priceData of Object.values(currencyData)) {
const price_data = currency_data[currency] for (const size in priceData) {
for (let size in price_data) { sizes.add(size)
const price = price_data[size]
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)
})
} }
} }
// Generate plans in settings
for (const size of sizes) {
Settings.plans.push({
planCode: `group_${planCode}_${size}_${usage}`,
name: `${Settings.appName} ${capitalize(
planCode
)} - Group Account (${size} licenses) - ${capitalize(usage)}`,
hideFromUsers: true,
annual: true,
features: Settings.features[planCode],
groupPlan: true,
membersLimit: parseInt(size),
membersLimitAddOn: 'additional-license'
})
}
} }
} }

View file

@ -1,34 +1,16 @@
/* eslint-disable
node/handle-callback-err,
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* 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
*/
const Settings = require('settings-sharelatex') const Settings = require('settings-sharelatex')
const RecurlyWrapper = require('./RecurlyWrapper') const RecurlyWrapper = require('./RecurlyWrapper')
const PlansLocator = require('./PlansLocator') const PlansLocator = require('./PlansLocator')
const SubscriptionFormatters = require('./SubscriptionFormatters') const SubscriptionFormatters = require('./SubscriptionFormatters')
const LimitationsManager = require('./LimitationsManager')
const SubscriptionLocator = require('./SubscriptionLocator') const SubscriptionLocator = require('./SubscriptionLocator')
const V1SubscriptionManager = require('./V1SubscriptionManager') const V1SubscriptionManager = require('./V1SubscriptionManager')
const InstitutionsGetter = require('../Institutions/InstitutionsGetter') const InstitutionsGetter = require('../Institutions/InstitutionsGetter')
const PublishersGetter = require('../Publishers/PublishersGetter') const PublishersGetter = require('../Publishers/PublishersGetter')
const sanitizeHtml = require('sanitize-html') const sanitizeHtml = require('sanitize-html')
const logger = require('logger-sharelatex')
const _ = require('underscore') const _ = require('underscore')
const async = require('async') const async = require('async')
const buildHostedLink = function(recurlySubscription, type) { function buildHostedLink(recurlySubscription, type) {
const recurlySubdomain = Settings.apis.recurly.subdomain const recurlySubdomain = Settings.apis.recurly.subdomain
const hostedLoginToken = recurlySubscription.account.hosted_login_token const hostedLoginToken = recurlySubscription.account.hosted_login_token
let path = '' let path = ''
@ -48,28 +30,22 @@ const buildHostedLink = function(recurlySubscription, type) {
module.exports = { module.exports = {
buildUsersSubscriptionViewModel(user, callback) { buildUsersSubscriptionViewModel(user, callback) {
if (callback == null) { async.auto(
callback = function(error, data) {}
}
return async.auto(
{ {
personalSubscription(cb) { personalSubscription(cb) {
return SubscriptionLocator.getUsersSubscription(user, cb) SubscriptionLocator.getUsersSubscription(user, cb)
}, },
recurlySubscription: [ recurlySubscription: [
'personalSubscription', 'personalSubscription',
function(cb, { personalSubscription }) { (cb, { personalSubscription }) => {
if ( if (
(personalSubscription != null personalSubscription == null ||
? personalSubscription.recurlySubscription_id personalSubscription.recurlySubscription_id == null ||
: undefined) == null || personalSubscription.recurlySubscription_id === ''
(personalSubscription != null
? personalSubscription.recurlySubscription_id
: undefined) === ''
) { ) {
return cb(null, null) return cb(null, null)
} }
return RecurlyWrapper.getSubscription( RecurlyWrapper.getSubscription(
personalSubscription.recurlySubscription_id, personalSubscription.recurlySubscription_id,
{ includeAccount: true }, { includeAccount: true },
cb cb
@ -78,17 +54,17 @@ module.exports = {
], ],
recurlyCoupons: [ recurlyCoupons: [
'recurlySubscription', 'recurlySubscription',
function(cb, { recurlySubscription }) { (cb, { recurlySubscription }) => {
if (!recurlySubscription) { if (!recurlySubscription) {
return cb(null, null) return cb(null, null)
} }
const accountId = recurlySubscription.account.account_code const accountId = recurlySubscription.account.account_code
return RecurlyWrapper.getAccountActiveCoupons(accountId, cb) RecurlyWrapper.getAccountActiveCoupons(accountId, cb)
} }
], ],
plan: [ plan: [
'personalSubscription', 'personalSubscription',
function(cb, { personalSubscription }) { (cb, { personalSubscription }) => {
if (personalSubscription == null) { if (personalSubscription == null) {
return cb() return cb()
} }
@ -102,38 +78,38 @@ module.exports = {
) )
) )
} }
return cb(null, plan) cb(null, plan)
} }
], ],
memberGroupSubscriptions(cb) { memberGroupSubscriptions(cb) {
return SubscriptionLocator.getMemberSubscriptions(user, cb) SubscriptionLocator.getMemberSubscriptions(user, cb)
}, },
managedGroupSubscriptions(cb) { managedGroupSubscriptions(cb) {
return SubscriptionLocator.getManagedGroupSubscriptions(user, cb) SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
}, },
confirmedMemberAffiliations(cb) { confirmedMemberAffiliations(cb) {
return InstitutionsGetter.getConfirmedAffiliations(user._id, cb) InstitutionsGetter.getConfirmedAffiliations(user._id, cb)
}, },
managedInstitutions(cb) { managedInstitutions(cb) {
return InstitutionsGetter.getManagedInstitutions(user._id, cb) InstitutionsGetter.getManagedInstitutions(user._id, cb)
}, },
managedPublishers(cb) { managedPublishers(cb) {
return PublishersGetter.getManagedPublishers(user._id, cb) PublishersGetter.getManagedPublishers(user._id, cb)
}, },
v1SubscriptionStatus(cb) { v1SubscriptionStatus(cb) {
return V1SubscriptionManager.getSubscriptionStatusFromV1( V1SubscriptionManager.getSubscriptionStatusFromV1(
user._id, user._id,
function(error, status, v1Id) { (error, status, v1Id) => {
if (error != null) { if (error) {
return cb(error) return cb(error)
} }
return cb(null, status) cb(null, status)
} }
) )
} }
}, },
function(err, results) { (err, results) => {
if (err != null) { if (err) {
return callback(err) return callback(err)
} }
let { let {
@ -168,9 +144,8 @@ module.exports = {
} }
if ( if (
(personalSubscription != null personalSubscription &&
? personalSubscription.toObject typeof personalSubscription.toObject === 'function'
: undefined) != null
) { ) {
// Downgrade from Mongoose object, so we can add a recurly and plan attribute // Downgrade from Mongoose object, so we can add a recurly and plan attribute
personalSubscription = personalSubscription.toObject() personalSubscription = personalSubscription.toObject()
@ -180,15 +155,13 @@ module.exports = {
personalSubscription.plan = plan personalSubscription.plan = plan
} }
if (personalSubscription != null && recurlySubscription != null) { if (personalSubscription && recurlySubscription) {
const tax = const tax = recurlySubscription.tax_in_cents || 0
(recurlySubscription != null
? recurlySubscription.tax_in_cents
: undefined) || 0
// Some plans allow adding more seats than the base plan provides. // Some plans allow adding more seats than the base plan provides.
// This is recorded as a subscription add on. // This is recorded as a subscription add on.
// Note: tax_in_cents already includes the tax for any addon. // Note: tax_in_cents already includes the tax for any addon.
let addOnPrice = 0 let addOnPrice = 0
let additionalLicenses = 0
if ( if (
plan.membersLimitAddOn && plan.membersLimitAddOn &&
Array.isArray(recurlySubscription.subscription_add_ons) Array.isArray(recurlySubscription.subscription_add_ons)
@ -196,18 +169,15 @@ module.exports = {
recurlySubscription.subscription_add_ons.forEach(addOn => { recurlySubscription.subscription_add_ons.forEach(addOn => {
if (addOn.add_on_code === plan.membersLimitAddOn) { if (addOn.add_on_code === plan.membersLimitAddOn) {
addOnPrice += addOn.quantity * addOn.unit_amount_in_cents addOnPrice += addOn.quantity * addOn.unit_amount_in_cents
additionalLicenses += addOn.quantity
} }
}) })
} }
const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
personalSubscription.recurly = { personalSubscription.recurly = {
tax, tax,
taxRate: parseFloat( taxRate: parseFloat(
__guard__( recurlySubscription.tax_rate && recurlySubscription.tax_rate._
recurlySubscription != null
? recurlySubscription.tax_rate
: undefined,
x => x._
)
), ),
billingDetailsLink: buildHostedLink( billingDetailsLink: buildHostedLink(
recurlySubscription, recurlySubscription,
@ -215,26 +185,18 @@ module.exports = {
), ),
accountManagementLink: buildHostedLink(recurlySubscription), accountManagementLink: buildHostedLink(recurlySubscription),
price: SubscriptionFormatters.formatPrice( price: SubscriptionFormatters.formatPrice(
(recurlySubscription != null recurlySubscription.unit_amount_in_cents + addOnPrice + tax,
? recurlySubscription.unit_amount_in_cents recurlySubscription.currency
: undefined) +
addOnPrice +
tax,
recurlySubscription != null
? recurlySubscription.currency
: undefined
), ),
additionalLicenses,
totalLicenses,
nextPaymentDueAt: SubscriptionFormatters.formatDate( nextPaymentDueAt: SubscriptionFormatters.formatDate(
recurlySubscription != null recurlySubscription.current_period_ends_at
? recurlySubscription.current_period_ends_at
: undefined
), ),
currency: recurlySubscription.currency, currency: recurlySubscription.currency,
state: recurlySubscription.state, state: recurlySubscription.state,
trialEndsAtFormatted: SubscriptionFormatters.formatDate( trialEndsAtFormatted: SubscriptionFormatters.formatDate(
recurlySubscription != null recurlySubscription.trial_ends_at
? recurlySubscription.trial_ends_at
: undefined
), ),
trial_ends_at: recurlySubscription.trial_ends_at, trial_ends_at: recurlySubscription.trial_ends_at,
activeCoupons: recurlyCoupons, activeCoupons: recurlyCoupons,
@ -242,9 +204,7 @@ module.exports = {
} }
} }
for (let memberGroupSubscription of Array.from( for (const memberGroupSubscription of memberGroupSubscriptions) {
memberGroupSubscriptions
)) {
if (memberGroupSubscription.teamNotice) { if (memberGroupSubscription.teamNotice) {
memberGroupSubscription.teamNotice = sanitizeHtml( memberGroupSubscription.teamNotice = sanitizeHtml(
memberGroupSubscription.teamNotice memberGroupSubscription.teamNotice
@ -252,7 +212,7 @@ module.exports = {
} }
} }
return callback(null, { callback(null, {
personalSubscription, personalSubscription,
managedGroupSubscriptions, managedGroupSubscriptions,
memberGroupSubscriptions, memberGroupSubscriptions,
@ -308,9 +268,3 @@ module.exports = {
return result return result
} }
} }
function __guard__(value, transform) {
return typeof value !== 'undefined' && value !== null
? transform(value)
: undefined
}

View file

@ -8,7 +8,10 @@ div(ng-controller="RecurlySubscriptionController")
case personalSubscription.recurly.state case personalSubscription.recurly.state
when "active" when "active"
p !{translate("currently_subscribed_to_plan", {planName: personalSubscription.plan.name}, ['strong'])} p !{translate("currently_subscribed_to_plan", {planName: personalSubscription.plan.name}, ['strong'])}
|  -if (personalSubscription.recurly.additionalLicenses > 0)
|
| !{translate("additional_licenses", {additionalLicenses: personalSubscription.recurly.additionalLicenses, totalLicenses: personalSubscription.recurly.totalLicenses}, ['strong', 'strong'])}
|  
a(href, ng-click="switchToChangePlanView()", ng-if="showChangePlanButton") !{translate("change_plan")}. a(href, ng-click="switchToChangePlanView()", ng-if="showChangePlanButton") !{translate("change_plan")}.
-if (personalSubscription.recurly.trialEndsAtFormatted && personalSubscription.recurly.trial_ends_at > Date.now()) -if (personalSubscription.recurly.trialEndsAtFormatted && personalSubscription.recurly.trial_ends_at > Date.now())
p You're on a free trial which ends on <strong ng-non-bindable>#{personalSubscription.recurly.trialEndsAtFormatted}</strong> p You're on a free trial which ends on <strong ng-non-bindable>#{personalSubscription.recurly.trialEndsAtFormatted}</strong>

View file

@ -1005,6 +1005,7 @@
"currently_subscribed_to_plan": "You are currently subscribed to the <0>__planName__</0> plan.", "currently_subscribed_to_plan": "You are currently subscribed to the <0>__planName__</0> plan.",
"change_plan": "Change plan", "change_plan": "Change plan",
"next_payment_of_x_collectected_on_y": "The next payment of <0>__paymentAmmount__</0> will be collected on <1>__collectionDate__</1>.", "next_payment_of_x_collectected_on_y": "The next payment of <0>__paymentAmmount__</0> will be collected on <1>__collectionDate__</1>.",
"additional_licenses": "Your subscription includes <0>__additionalLicenses__</0> additional license(s) for a total of <1>__totalLicenses__</1> licenses.",
"update_your_billing_details": "Update Your Billing Details", "update_your_billing_details": "Update Your Billing Details",
"subscription_canceled_and_terminate_on_x": " Your subscription has been canceled and will terminate on <0>__terminateDate__</0>. No further payments will be taken.", "subscription_canceled_and_terminate_on_x": " Your subscription has been canceled and will terminate on <0>__terminateDate__</0>. No further payments will be taken.",
"your_subscription_has_expired": "Your subscription has expired.", "your_subscription_has_expired": "Your subscription has expired.",

View file

@ -134,7 +134,9 @@ describe('Subscriptions', function() {
account_code: this.user._id, account_code: this.user._id,
email: 'mock@email.com', email: 'mock@email.com',
hosted_login_token: 'mock-login-token' hosted_login_token: 'mock-login-token'
} },
additionalLicenses: 0,
totalLicenses: 0
}) })
}) })