SubscriptionViewModelBuilder.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. // ts-check
  2. import Settings from '@overleaf/settings'
  3. import PlansLocator from './PlansLocator.mjs'
  4. import { isStandaloneAiAddOnPlanCode } from './AiHelper.js'
  5. import PaymentProviderEntities from './PaymentProviderEntities.mjs'
  6. import SubscriptionFormatters from './SubscriptionFormatters.mjs'
  7. import SubscriptionLocator from './SubscriptionLocator.mjs'
  8. import InstitutionsGetter from '../Institutions/InstitutionsGetter.mjs'
  9. import InstitutionsManager from '../Institutions/InstitutionsManager.mjs'
  10. import PublishersGetter from '../Publishers/PublishersGetter.mjs'
  11. import sanitizeHtml from 'sanitize-html'
  12. import _ from 'lodash'
  13. import async from 'async'
  14. import SubscriptionHelper from './SubscriptionHelper.js'
  15. import { callbackify } from '@overleaf/promise-utils'
  16. import { V1ConnectionError } from '../Errors/Errors.js'
  17. import FeaturesHelper from './FeaturesHelper.mjs'
  18. import { formatCurrency } from '../../util/currency.js'
  19. import Modules from '../../infrastructure/Modules.js'
  20. import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
  21. const { MEMBERS_LIMIT_ADD_ON_CODE } = PaymentProviderEntities
  22. /**
  23. * @import { Subscription } from "../../../../types/project/dashboard/subscription"
  24. * @import { Subscription as DBSubscription } from "../../models/Subscription"
  25. */
  26. function buildHostedLink(type) {
  27. return `/user/subscription/payment/${type}`
  28. }
  29. // Downgrade from Mongoose object, so we can add custom attributes to object
  30. function serializeMongooseObject(object) {
  31. return object && typeof object.toObject === 'function'
  32. ? object.toObject()
  33. : object
  34. }
  35. async function buildUsersSubscriptionViewModel(user, locale = 'en') {
  36. let {
  37. personalSubscription,
  38. memberGroupSubscriptions,
  39. managedGroupSubscriptions,
  40. currentInstitutionsWithLicence,
  41. managedInstitutions,
  42. managedPublishers,
  43. fetchedPaymentRecord,
  44. plan,
  45. } = await async.auto({
  46. personalSubscription(cb) {
  47. SubscriptionLocator.getUsersSubscription(user, cb)
  48. },
  49. fetchedPaymentRecord: [
  50. 'personalSubscription',
  51. ({ personalSubscription }, cb) => {
  52. Modules.hooks.fire('getPaymentFromRecord', personalSubscription, cb)
  53. },
  54. ],
  55. plan: [
  56. 'personalSubscription',
  57. ({ personalSubscription }, cb) => {
  58. if (personalSubscription == null) {
  59. return cb()
  60. }
  61. const plan = PlansLocator.findLocalPlanInSettings(
  62. personalSubscription.planCode
  63. )
  64. if (plan == null) {
  65. return cb(
  66. new Error(
  67. `No plan found for planCode '${personalSubscription.planCode}'`
  68. )
  69. )
  70. }
  71. cb(null, plan)
  72. },
  73. ],
  74. memberGroupSubscriptions(cb) {
  75. SubscriptionLocator.getMemberSubscriptions(user, cb)
  76. },
  77. managedGroupSubscriptions(cb) {
  78. SubscriptionLocator.getManagedGroupSubscriptions(user, cb)
  79. },
  80. currentInstitutionsWithLicence(cb) {
  81. InstitutionsGetter.getCurrentInstitutionsWithLicence(
  82. user._id,
  83. (error, institutions) => {
  84. if (error instanceof V1ConnectionError) {
  85. return cb(null, false)
  86. }
  87. cb(null, institutions)
  88. }
  89. )
  90. },
  91. managedInstitutions(cb) {
  92. InstitutionsGetter.getManagedInstitutions(user._id, cb)
  93. },
  94. managedPublishers(cb) {
  95. PublishersGetter.getManagedPublishers(user._id, cb)
  96. },
  97. })
  98. const paymentRecord = fetchedPaymentRecord && fetchedPaymentRecord[0]
  99. if (memberGroupSubscriptions == null) {
  100. memberGroupSubscriptions = []
  101. } else {
  102. memberGroupSubscriptions = memberGroupSubscriptions.map(group => {
  103. const userIsGroupManager = group.manager_ids?.some(
  104. id => id.toString() === user._id.toString()
  105. )
  106. const groupDataForView = {
  107. _id: group._id,
  108. planCode: group.planCode,
  109. teamName: group.teamName,
  110. admin_id: { email: group.admin_id.email },
  111. userIsGroupManager,
  112. }
  113. if (group.teamNotice) {
  114. groupDataForView.teamNotice = sanitizeHtml(group.teamNotice)
  115. }
  116. buildGroupSubscriptionForView(groupDataForView)
  117. return groupDataForView
  118. })
  119. }
  120. if (managedGroupSubscriptions == null) {
  121. managedGroupSubscriptions = []
  122. } else {
  123. managedGroupSubscriptions = managedGroupSubscriptions.map(group => {
  124. const userIsGroupMember = group.member_ids?.some(
  125. id => id.toString() === user._id.toString()
  126. )
  127. const groupDataForView = {
  128. _id: group._id,
  129. planCode: group.planCode,
  130. groupPlan: group.groupPlan,
  131. teamName: group.teamName,
  132. admin_id: { _id: group.admin_id._id, email: group.admin_id.email },
  133. features: group.features,
  134. userIsGroupMember,
  135. }
  136. buildGroupSubscriptionForView(groupDataForView)
  137. return groupDataForView
  138. })
  139. }
  140. if (managedInstitutions == null) {
  141. managedInstitutions = []
  142. }
  143. personalSubscription = serializeMongooseObject(personalSubscription)
  144. managedInstitutions = managedInstitutions.map(serializeMongooseObject)
  145. await Promise.all(
  146. managedInstitutions.map(InstitutionsManager.promises.fetchV1Data)
  147. )
  148. managedPublishers = managedPublishers.map(serializeMongooseObject)
  149. await Promise.all(
  150. managedPublishers.map(PublishersGetter.promises.fetchV1Data)
  151. )
  152. if (plan != null) {
  153. personalSubscription.plan = plan
  154. }
  155. function getPlanOnlyDisplayPrice(
  156. totalPlanPriceInCents,
  157. taxRate,
  158. addOns = []
  159. ) {
  160. // The MEMBERS_LIMIT_ADD_ON_CODE is considered as part of the new plan model
  161. const allAddOnsPriceInCentsExceptAdditionalLicensePrice = addOns.reduce(
  162. (prev, curr) => {
  163. return curr.code !== MEMBERS_LIMIT_ADD_ON_CODE
  164. ? curr.quantity * curr.unitPrice + prev
  165. : prev
  166. },
  167. 0
  168. )
  169. const allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice =
  170. allAddOnsPriceInCentsExceptAdditionalLicensePrice +
  171. allAddOnsPriceInCentsExceptAdditionalLicensePrice * taxRate
  172. return formatCurrency(
  173. totalPlanPriceInCents -
  174. allAddOnsTotalPriceInCentsExceptAdditionalLicensePrice,
  175. paymentRecord.subscription.currency,
  176. locale
  177. )
  178. }
  179. function getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns = []) {
  180. return addOns.reduce((prev, curr) => {
  181. if (curr.code !== MEMBERS_LIMIT_ADD_ON_CODE) {
  182. const priceInCents = curr.quantity * curr.unitPrice
  183. const totalPriceInCents = priceInCents + priceInCents * taxRate
  184. if (totalPriceInCents > 0) {
  185. prev[curr.code] = formatCurrency(
  186. totalPriceInCents,
  187. paymentRecord.subscription.currency,
  188. locale
  189. )
  190. }
  191. }
  192. return prev
  193. }, {})
  194. }
  195. if (personalSubscription && paymentRecord && paymentRecord.subscription) {
  196. // don't return subscription payment information
  197. personalSubscription.service =
  198. personalSubscription.paymentProvider?.service ?? 'recurly'
  199. delete personalSubscription.paymentProvider
  200. delete personalSubscription.recurly
  201. delete personalSubscription.recurlySubscription_id
  202. const tax = paymentRecord.subscription.taxAmount || 0
  203. // Some plans allow adding more seats than the base plan provides.
  204. // This is recorded as a subscription add on.
  205. // Note: taxAmount already includes the tax for any addon.
  206. let addOnPrice = 0
  207. let additionalLicenses = 0
  208. const addOns = paymentRecord.subscription.addOns || []
  209. const taxRate = paymentRecord.subscription.taxRate
  210. addOns.forEach(addOn => {
  211. addOnPrice += addOn.quantity * addOn.unitPrice
  212. if (addOn.code === plan.membersLimitAddOn) {
  213. additionalLicenses += addOn.quantity
  214. }
  215. })
  216. const totalLicenses = (plan.membersLimit || 0) + additionalLicenses
  217. const isInTrial =
  218. paymentRecord.subscription.trialPeriodEnd &&
  219. paymentRecord.subscription.trialPeriodEnd.getTime() > Date.now()
  220. let isEligibleForPause = false
  221. const commonPauseConditions =
  222. !personalSubscription.pendingPlan &&
  223. !personalSubscription.groupPlan &&
  224. !isInTrial &&
  225. !paymentRecord.subscription.planCode.includes('ann') &&
  226. !paymentRecord.subscription.addOns?.length
  227. if (
  228. paymentRecord.subscription.service === 'recurly' &&
  229. commonPauseConditions
  230. ) {
  231. isEligibleForPause = true
  232. } else if (
  233. paymentRecord.subscription.service.includes('stripe') &&
  234. commonPauseConditions
  235. ) {
  236. const stripePauseAssignment =
  237. await SplitTestHandler.promises.getAssignmentForUser(
  238. user._id,
  239. 'stripe-pause'
  240. )
  241. isEligibleForPause = stripePauseAssignment.variant === 'enabled'
  242. }
  243. personalSubscription.payment = {
  244. taxRate,
  245. billingDetailsLink:
  246. paymentRecord.subscription.service === 'recurly'
  247. ? buildHostedLink('billing-details')
  248. : null,
  249. accountManagementLink: buildHostedLink('account-management'),
  250. additionalLicenses,
  251. addOns,
  252. totalLicenses,
  253. nextPaymentDueAt: SubscriptionFormatters.formatDateTime(
  254. paymentRecord.subscription.periodEnd
  255. ),
  256. nextPaymentDueDate: SubscriptionFormatters.formatDate(
  257. paymentRecord.subscription.periodEnd
  258. ),
  259. currency: paymentRecord.subscription.currency,
  260. state: paymentRecord.subscription.state,
  261. trialEndsAtFormatted: SubscriptionFormatters.formatDateTime(
  262. paymentRecord.subscription.trialPeriodEnd
  263. ),
  264. trialEndsAt: paymentRecord.subscription.trialPeriodEnd,
  265. activeCoupons: paymentRecord.coupons,
  266. accountEmail: paymentRecord.account.email,
  267. hasPastDueInvoice: paymentRecord.account.hasPastDueInvoice,
  268. pausedAt: paymentRecord.subscription.pausePeriodStart,
  269. remainingPauseCycles: paymentRecord.subscription.remainingPauseCycles,
  270. isEligibleForPause,
  271. isEligibleForGroupPlan: !isInTrial,
  272. }
  273. const isMonthlyCollaboratorPlan =
  274. personalSubscription.planCode.includes('collaborator') &&
  275. !personalSubscription.planCode.includes('ann') &&
  276. !personalSubscription.plan.groupPlan
  277. personalSubscription.payment.isEligibleForDowngradeUpsell =
  278. !personalSubscription.payment.pausedAt &&
  279. !personalSubscription.payment.remainingPauseCycles &&
  280. isMonthlyCollaboratorPlan &&
  281. !isInTrial &&
  282. paymentRecord.subscription.service === 'recurly'
  283. if (paymentRecord.subscription.pendingChange) {
  284. const pendingPlanCode =
  285. paymentRecord.subscription.pendingChange.nextPlanCode
  286. const pendingPlan = PlansLocator.findLocalPlanInSettings(pendingPlanCode)
  287. if (pendingPlan == null) {
  288. throw new Error(`No plan found for planCode '${pendingPlanCode}'`)
  289. }
  290. let pendingAdditionalLicenses = 0
  291. let pendingAddOnTax = 0
  292. let pendingAddOnPrice = 0
  293. if (paymentRecord.subscription.pendingChange.nextAddOns) {
  294. const pendingAddOns =
  295. paymentRecord.subscription.pendingChange.nextAddOns
  296. pendingAddOns.forEach(addOn => {
  297. pendingAddOnPrice += addOn.quantity * addOn.unitPrice
  298. if (addOn.code === pendingPlan.membersLimitAddOn) {
  299. pendingAdditionalLicenses += addOn.quantity
  300. }
  301. })
  302. // Need to calculate tax ourselves as we don't get tax amounts for pending subs
  303. pendingAddOnTax =
  304. personalSubscription.payment.taxRate * pendingAddOnPrice
  305. pendingPlan.addOns = pendingAddOns
  306. }
  307. const pendingSubscriptionTax =
  308. personalSubscription.payment.taxRate *
  309. paymentRecord.subscription.pendingChange.nextPlanPrice
  310. const totalPrice =
  311. paymentRecord.subscription.pendingChange.nextPlanPrice +
  312. pendingAddOnPrice +
  313. pendingAddOnTax +
  314. pendingSubscriptionTax
  315. personalSubscription.payment.displayPrice = formatCurrency(
  316. totalPrice,
  317. paymentRecord.subscription.currency,
  318. locale
  319. )
  320. personalSubscription.payment.planOnlyDisplayPrice =
  321. getPlanOnlyDisplayPrice(
  322. totalPrice,
  323. taxRate,
  324. paymentRecord.subscription.pendingChange.nextAddOns
  325. )
  326. personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
  327. getAddOnDisplayPricesWithoutAdditionalLicense(
  328. taxRate,
  329. paymentRecord.subscription.pendingChange.nextAddOns
  330. )
  331. const pendingTotalLicenses =
  332. (pendingPlan.membersLimit || 0) + pendingAdditionalLicenses
  333. personalSubscription.payment.pendingAdditionalLicenses =
  334. pendingAdditionalLicenses
  335. personalSubscription.payment.pendingTotalLicenses = pendingTotalLicenses
  336. personalSubscription.pendingPlan = pendingPlan
  337. } else {
  338. const totalPrice = paymentRecord.subscription.planPrice + addOnPrice + tax
  339. personalSubscription.payment.displayPrice = formatCurrency(
  340. totalPrice,
  341. paymentRecord.subscription.currency,
  342. locale
  343. )
  344. personalSubscription.payment.planOnlyDisplayPrice =
  345. getPlanOnlyDisplayPrice(totalPrice, taxRate, addOns)
  346. personalSubscription.payment.addOnDisplayPricesWithoutAdditionalLicense =
  347. getAddOnDisplayPricesWithoutAdditionalLicense(taxRate, addOns)
  348. }
  349. }
  350. return {
  351. personalSubscription,
  352. managedGroupSubscriptions,
  353. memberGroupSubscriptions,
  354. currentInstitutionsWithLicence,
  355. managedInstitutions,
  356. managedPublishers,
  357. }
  358. }
  359. /**
  360. * @param {{_id: string}} user
  361. * @returns {Promise<{bestSubscription:Subscription,individualSubscription:DBSubscription|null,memberGroupSubscriptions:DBSubscription[]}>}
  362. */
  363. async function getUsersSubscriptionDetails(user) {
  364. let [
  365. individualSubscription,
  366. memberGroupSubscriptions,
  367. currentInstitutionsWithLicence,
  368. ] = await Promise.all([
  369. SubscriptionLocator.promises.getUsersSubscription(user),
  370. SubscriptionLocator.promises.getMemberSubscriptions(user),
  371. InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(user._id),
  372. ])
  373. if (
  374. individualSubscription &&
  375. !individualSubscription.customAccount &&
  376. SubscriptionHelper.getPaymentProviderSubscriptionId(
  377. individualSubscription
  378. ) &&
  379. !SubscriptionHelper.getPaidSubscriptionState(individualSubscription)
  380. ) {
  381. const paymentResults = await Modules.promises.hooks.fire(
  382. 'getPaymentFromRecordPromise',
  383. individualSubscription
  384. )
  385. await Modules.promises.hooks.fire(
  386. 'syncSubscription',
  387. paymentResults[0]?.subscription,
  388. individualSubscription
  389. )
  390. individualSubscription =
  391. await SubscriptionLocator.promises.getUsersSubscription(user)
  392. }
  393. let bestSubscription = { type: 'free' }
  394. if (currentInstitutionsWithLicence?.length) {
  395. for (const institutionMembership of currentInstitutionsWithLicence) {
  396. const plan = PlansLocator.findLocalPlanInSettings(
  397. Settings.institutionPlanCode
  398. )
  399. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  400. bestSubscription = {
  401. type: 'commons',
  402. subscription: institutionMembership,
  403. plan,
  404. }
  405. }
  406. }
  407. }
  408. if (memberGroupSubscriptions?.length) {
  409. for (const groupSubscription of memberGroupSubscriptions) {
  410. const plan = PlansLocator.findLocalPlanInSettings(
  411. groupSubscription.planCode
  412. )
  413. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  414. const groupDataForView = {}
  415. if (groupSubscription.teamName) {
  416. groupDataForView.teamName = groupSubscription.teamName
  417. }
  418. const remainingTrialDays = _getRemainingTrialDays(groupSubscription)
  419. bestSubscription = {
  420. type: 'group',
  421. subscription: groupDataForView,
  422. plan,
  423. remainingTrialDays,
  424. }
  425. }
  426. }
  427. }
  428. if (individualSubscription && !individualSubscription.groupPlan) {
  429. if (
  430. isStandaloneAiAddOnPlanCode(individualSubscription.planCode) &&
  431. bestSubscription.type === 'free'
  432. ) {
  433. bestSubscription = { type: 'standalone-ai-add-on' }
  434. } else {
  435. const plan = PlansLocator.findLocalPlanInSettings(
  436. individualSubscription.planCode
  437. )
  438. if (_isPlanEqualOrBetter(plan, bestSubscription.plan)) {
  439. const remainingTrialDays = _getRemainingTrialDays(
  440. individualSubscription
  441. )
  442. bestSubscription = {
  443. type: 'individual',
  444. subscription: individualSubscription,
  445. plan,
  446. remainingTrialDays,
  447. }
  448. }
  449. }
  450. }
  451. return { bestSubscription, individualSubscription, memberGroupSubscriptions }
  452. }
  453. function buildPlansList(currentPlan, isInTrial) {
  454. const { plans } = Settings
  455. const allPlans = {}
  456. plans.forEach(plan => {
  457. allPlans[plan.planCode] = plan
  458. })
  459. const result = { allPlans }
  460. if (currentPlan) {
  461. result.planCodesChangingAtTermEnd = _.map(
  462. _.filter(plans, plan => {
  463. if (!plan.hideFromUsers) {
  464. return SubscriptionHelper.shouldPlanChangeAtTermEnd(
  465. currentPlan,
  466. plan,
  467. isInTrial
  468. )
  469. }
  470. }),
  471. 'planCode'
  472. )
  473. }
  474. result.studentAccounts = _.filter(
  475. plans,
  476. plan => plan.planCode.indexOf('student') !== -1
  477. )
  478. result.groupMonthlyPlans = _.filter(
  479. plans,
  480. plan => plan.groupPlan && !plan.annual
  481. )
  482. result.groupAnnualPlans = _.filter(
  483. plans,
  484. plan => plan.groupPlan && plan.annual
  485. )
  486. result.individualMonthlyPlans = _.filter(
  487. plans,
  488. plan =>
  489. !plan.groupPlan &&
  490. !plan.annual &&
  491. plan.planCode !== 'personal' && // Prevent the personal plan from appearing on the change-plans page
  492. plan.planCode.indexOf('student') === -1
  493. )
  494. result.individualAnnualPlans = _.filter(
  495. plans,
  496. plan =>
  497. !plan.groupPlan && plan.annual && plan.planCode.indexOf('student') === -1
  498. )
  499. return result
  500. }
  501. function _isPlanEqualOrBetter(planA, planB) {
  502. return FeaturesHelper.isFeatureSetBetter(
  503. planA?.features || {},
  504. planB?.features || {}
  505. )
  506. }
  507. function _getRemainingTrialDays(subscription) {
  508. const now = new Date()
  509. const trialEndDate =
  510. SubscriptionHelper.getSubscriptionTrialEndsAt(subscription)
  511. return trialEndDate && trialEndDate > now
  512. ? Math.ceil(
  513. (trialEndDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
  514. )
  515. : -1
  516. }
  517. function buildGroupSubscriptionForView(groupSubscription) {
  518. // most group plans in Recurly should be in form "group_plancode_size_usage"
  519. const planLevelFromGroupPlanCode = groupSubscription.planCode.substr(6, 12)
  520. if (planLevelFromGroupPlanCode === 'professional') {
  521. groupSubscription.planLevelName = 'Professional'
  522. } else if (planLevelFromGroupPlanCode === 'collaborator') {
  523. groupSubscription.planLevelName = 'Standard'
  524. }
  525. // there are some group subscription entries that have the personal plancodes...
  526. // this fallback tries to still show the right thing in these cases:
  527. if (!groupSubscription.planLevelName) {
  528. if (groupSubscription.planCode.startsWith('professional')) {
  529. groupSubscription.planLevelName = 'Professional'
  530. } else if (groupSubscription.planCode.startsWith('collaborator')) {
  531. groupSubscription.planLevelName = 'Standard'
  532. } else {
  533. // if we still don't have anything, we can show the plan name (eg, v1 Pro):
  534. const plan = PlansLocator.findLocalPlanInSettings(
  535. groupSubscription.planCode
  536. )
  537. groupSubscription.planLevelName = plan
  538. ? plan.name
  539. : groupSubscription.planCode
  540. }
  541. }
  542. }
  543. function buildPlansListForSubscriptionDash(currentPlan, isInTrial) {
  544. const allPlansData = buildPlansList(currentPlan, isInTrial)
  545. const plans = []
  546. // only list individual and visible plans for "change plans" UI
  547. if (allPlansData.studentAccounts) {
  548. plans.push(
  549. ...allPlansData.studentAccounts.filter(plan => !plan.hideFromUsers)
  550. )
  551. }
  552. if (allPlansData.individualMonthlyPlans) {
  553. plans.push(
  554. ...allPlansData.individualMonthlyPlans.filter(plan => !plan.hideFromUsers)
  555. )
  556. }
  557. if (allPlansData.individualAnnualPlans) {
  558. plans.push(
  559. ...allPlansData.individualAnnualPlans.filter(plan => !plan.hideFromUsers)
  560. )
  561. }
  562. return {
  563. plans,
  564. planCodesChangingAtTermEnd: allPlansData.planCodesChangingAtTermEnd,
  565. }
  566. }
  567. export default {
  568. buildUsersSubscriptionViewModel: callbackify(buildUsersSubscriptionViewModel),
  569. buildPlansList,
  570. buildPlansListForSubscriptionDash,
  571. promises: { buildUsersSubscriptionViewModel, getUsersSubscriptionDetails },
  572. }