PlansLocator.mjs 6.0 KB

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