PlansLocator.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. // @ts-check
  2. const Settings = require('@overleaf/settings')
  3. const logger = require('@overleaf/logger')
  4. /**
  5. * @typedef {import('../../../../types/subscription/plan').RecurlyPlanCode} RecurlyPlanCode
  6. * @typedef {import('../../../../types/subscription/plan').StripeLookupKey} StripeLookupKey
  7. * @typedef {import('../../../../types/subscription/plan').StripeBaseLookupKey} StripeBaseLookupKey
  8. * @typedef {import('../../../../types/subscription/plan').Plan} Plan
  9. * @typedef {import('../../../../types/subscription/currency').StripeCurrencyCode} StripeCurrencyCode
  10. * @typedef {import('stripe').Stripe.Price.Recurring.Interval} BillingCycleInterval
  11. */
  12. function ensurePlansAreSetupCorrectly() {
  13. Settings.plans.forEach(plan => {
  14. if (typeof plan.price_in_cents !== 'number') {
  15. logger.fatal({ plan }, 'missing price on plan')
  16. process.exit(1)
  17. }
  18. if (plan.price) {
  19. logger.fatal({ plan }, 'unclear price attribute on plan')
  20. process.exit(1)
  21. }
  22. if (plan.price_in_unit) {
  23. logger.fatal({ plan }, 'deprecated price_in_unit attribute on plan')
  24. process.exit(1)
  25. }
  26. })
  27. }
  28. /**
  29. * @type {Record<RecurlyPlanCode, StripeBaseLookupKey>}
  30. */
  31. const recurlyCodeToStripeBaseLookupKey = {
  32. collaborator: 'standard_monthly',
  33. 'collaborator-annual': 'standard_annual',
  34. collaborator_free_trial_7_days: 'standard_monthly',
  35. professional: 'professional_monthly',
  36. 'professional-annual': 'professional_annual',
  37. professional_free_trial_7_days: 'professional_monthly',
  38. student: 'student_monthly',
  39. 'student-annual': 'student_annual',
  40. student_free_trial_7_days: 'student_monthly',
  41. // TODO: change all group plans' lookup_keys to match the UK account after they have been added
  42. group_collaborator: 'group_standard_enterprise',
  43. group_collaborator_educational: 'group_standard_educational',
  44. group_professional: 'group_professional_enterprise',
  45. group_professional_educational: 'group_professional_educational',
  46. assistant: 'assistant_monthly',
  47. 'assistant-annual': 'assistant_annual',
  48. }
  49. const LATEST_STRIPE_LOOKUP_KEY_VERSION = 'jun2025'
  50. /**
  51. * Build the Stripe lookup key, will be in this format:
  52. * `${productCode}_${billingInterval}_${latestVersion}_${currency}`
  53. * (for example: 'assistant_annual_jun2025_clp')
  54. *
  55. * @param {RecurlyPlanCode} recurlyCode
  56. * @param {StripeCurrencyCode} currency
  57. * @param {BillingCycleInterval} [billingCycleInterval] -- needed for handling 'assistant' add-on
  58. * @returns {StripeLookupKey|null}
  59. */
  60. function buildStripeLookupKey(recurlyCode, currency, billingCycleInterval) {
  61. let stripeBaseLookupKey = recurlyCodeToStripeBaseLookupKey[recurlyCode]
  62. // Recurly always uses 'assistant' as the code regardless of the subscription duration
  63. if (recurlyCode === 'assistant' && billingCycleInterval) {
  64. if (billingCycleInterval === 'month') {
  65. stripeBaseLookupKey = 'assistant_monthly'
  66. }
  67. if (billingCycleInterval === 'year') {
  68. stripeBaseLookupKey = 'assistant_annual'
  69. }
  70. }
  71. if (stripeBaseLookupKey == null) {
  72. return null
  73. }
  74. return `${stripeBaseLookupKey}_${LATEST_STRIPE_LOOKUP_KEY_VERSION}_${currency}`
  75. }
  76. /**
  77. * @typedef {{ planType: 'individual' | 'group' | 'student' | null, period: 'annual' | 'monthly' }} PlanTypeAndPeriod
  78. * @type {Record<RecurlyPlanCode, PlanTypeAndPeriod>}
  79. */
  80. const recurlyPlanCodeToPlanTypeAndPeriod = {
  81. collaborator: { planType: 'individual', period: 'monthly' },
  82. 'collaborator-annual': { planType: 'individual', period: 'annual' },
  83. collaborator_free_trial_7_days: { planType: 'individual', period: 'monthly' },
  84. professional: { planType: 'individual', period: 'monthly' },
  85. 'professional-annual': { planType: 'individual', period: 'annual' },
  86. professional_free_trial_7_days: {
  87. planType: 'individual',
  88. period: 'monthly',
  89. },
  90. student: { planType: 'student', period: 'monthly' },
  91. 'student-annual': { planType: 'student', period: 'annual' },
  92. student_free_trial_7_days: { planType: 'student', period: 'monthly' },
  93. group_collaborator: { planType: 'group', period: 'annual' },
  94. group_collaborator_educational: { planType: 'group', period: 'annual' },
  95. group_professional: { planType: 'group', period: 'annual' },
  96. group_professional_educational: { planType: 'group', period: 'annual' },
  97. assistant: { planType: null, period: 'monthly' },
  98. 'assistant-annual': { planType: null, period: 'annual' },
  99. }
  100. /**
  101. * @param {RecurlyPlanCode} recurlyPlanCode
  102. * @returns {PlanTypeAndPeriod}
  103. */
  104. function getPlanTypeAndPeriodFromRecurlyPlanCode(recurlyPlanCode) {
  105. return recurlyPlanCodeToPlanTypeAndPeriod[recurlyPlanCode]
  106. }
  107. /**
  108. * @param {string|null} [planCode]
  109. * @returns {Plan|null}
  110. */
  111. function findLocalPlanInSettings(planCode) {
  112. for (const plan of Settings.plans) {
  113. if (plan.planCode === planCode) {
  114. return plan
  115. }
  116. }
  117. return null
  118. }
  119. /**
  120. * Returns whether the given plan code is a group plan
  121. *
  122. * @param {string} planCode
  123. */
  124. function isGroupPlanCode(planCode) {
  125. return planCode.includes('group')
  126. }
  127. /**
  128. * Adapts a legacy Recurly group plan code (e.g., `group_professional_5_educational`)
  129. * into its corresponding Stripe-compatible plan code (e.g., `group_professional_educational`),
  130. * extracting the license quantity where applicable.
  131. *
  132. * @param {RecurlyPlanCode} planCode
  133. * @returns {{ planCode: RecurlyPlanCode, quantity: number }}
  134. */
  135. function convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
  136. planCode
  137. ) {
  138. const pattern =
  139. /^group_(collaborator|professional)_(2|3|4|5|10|20|50)_(educational|enterprise)$/
  140. const match = planCode.match(pattern)
  141. if (match == null) {
  142. return { planCode, quantity: 1 }
  143. }
  144. const [, tier, size, usage] = match
  145. const newPlanCode = /** @type {RecurlyPlanCode} */ (
  146. usage === 'enterprise' ? `group_${tier}` : `group_${tier}_${usage}`
  147. )
  148. return { planCode: newPlanCode, quantity: Number(size) }
  149. }
  150. module.exports = {
  151. ensurePlansAreSetupCorrectly,
  152. findLocalPlanInSettings,
  153. buildStripeLookupKey,
  154. getPlanTypeAndPeriodFromRecurlyPlanCode,
  155. isGroupPlanCode,
  156. convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded,
  157. }