CustomerIoPlanHelpers.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. import Settings from '@overleaf/settings'
  2. import { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } from './AiHelper.mjs'
  3. import FeaturesHelper from './FeaturesHelper.mjs'
  4. const INACTIVE_NEXT_RENEWAL_DATE_STATES = new Set([
  5. 'canceled',
  6. 'cancelled',
  7. 'expired',
  8. ])
  9. const PENDING_CANCELLATION_STATES = new Set(['canceled', 'cancelled'])
  10. function getSubscriptionState(subscription) {
  11. return (
  12. subscription?.recurlyStatus?.state || subscription?.paymentProvider?.state
  13. )
  14. }
  15. function toUnixTimestamp(dateValue) {
  16. if (!dateValue) {
  17. return null
  18. }
  19. const date = new Date(dateValue)
  20. if (Number.isNaN(date.getTime())) {
  21. return null
  22. }
  23. return Math.floor(date.getTime() / 1000)
  24. }
  25. function normalizePlanType(bestSubscription) {
  26. if (!bestSubscription) {
  27. return null
  28. }
  29. if (['standalone-ai-add-on', 'commons'].includes(bestSubscription.type)) {
  30. return bestSubscription.type
  31. }
  32. const planCode = bestSubscription.plan?.planCode
  33. const isGroupPlan = bestSubscription.plan?.groupPlan === true
  34. if (!planCode) {
  35. return bestSubscription.type || null
  36. }
  37. if (planCode.startsWith('v1_')) {
  38. return 'v1'
  39. }
  40. if (planCode.includes('student')) {
  41. return 'student'
  42. }
  43. if (planCode.includes('professional')) {
  44. return isGroupPlan ? 'group-professional' : 'professional'
  45. }
  46. if (planCode.includes('collaborator')) {
  47. return isGroupPlan ? 'group-standard' : 'standard'
  48. }
  49. if (planCode.includes('personal')) {
  50. return 'personal'
  51. }
  52. if (isGroupPlan) {
  53. return 'group-standard'
  54. }
  55. return planCode
  56. }
  57. function getFriendlyPlanName(planType) {
  58. if (!planType) {
  59. return null
  60. }
  61. const friendlyPlanNames = {
  62. free: 'Free',
  63. personal: 'Personal',
  64. standard: 'Standard',
  65. professional: 'Pro',
  66. student: 'Student',
  67. commons: 'Commons',
  68. 'group-standard': 'Group Standard',
  69. 'group-professional': 'Group Pro',
  70. 'standalone-ai-add-on': 'AI Assist add-on',
  71. v1: 'Legacy',
  72. }
  73. if (friendlyPlanNames[planType]) {
  74. return friendlyPlanNames[planType]
  75. }
  76. return planType
  77. }
  78. function getPlanCadence(bestSubscription) {
  79. if (!bestSubscription?.plan) {
  80. return null
  81. }
  82. return bestSubscription.plan.annual ? 'annual' : 'monthly'
  83. }
  84. function getPlanCadenceFromPlanCode(planCode) {
  85. if (!planCode) {
  86. return null
  87. }
  88. const plan = Settings.plans.find(candidate => candidate.planCode === planCode)
  89. if (plan) {
  90. return plan.annual ? 'annual' : 'monthly'
  91. }
  92. if (planCode.includes('annual')) {
  93. return 'annual'
  94. }
  95. if (isStandaloneAiAddOnPlanCode(planCode)) {
  96. return 'monthly'
  97. }
  98. return null
  99. }
  100. function getNextRenewalDateFromPaymentRecord(paymentRecord) {
  101. const subscriptionState = paymentRecord?.subscription?.state
  102. if (INACTIVE_NEXT_RENEWAL_DATE_STATES.has(subscriptionState)) {
  103. return null
  104. }
  105. return toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
  106. }
  107. function shouldClearNextRenewalDate(subscription) {
  108. if (!subscription) {
  109. return true
  110. }
  111. return INACTIVE_NEXT_RENEWAL_DATE_STATES.has(
  112. getSubscriptionState(subscription)
  113. )
  114. }
  115. function getExpiryDateFromPaymentRecord(paymentRecord) {
  116. const subscriptionState = paymentRecord?.subscription?.state
  117. if (!PENDING_CANCELLATION_STATES.has(subscriptionState)) {
  118. return null
  119. }
  120. const expiryDate = toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
  121. if (expiryDate == null) {
  122. return null
  123. }
  124. return expiryDate > Math.floor(Date.now() / 1000) ? expiryDate : null
  125. }
  126. function shouldClearExpiryDate(subscription) {
  127. if (!subscription) {
  128. return true
  129. }
  130. return !PENDING_CANCELLATION_STATES.has(getSubscriptionState(subscription))
  131. }
  132. function getTrialEndDate(individualSubscription) {
  133. const trialEndsAt =
  134. individualSubscription?.recurlyStatus?.trialEndsAt ||
  135. individualSubscription?.paymentProvider?.trialEndsAt
  136. return toUnixTimestamp(trialEndsAt)
  137. }
  138. function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
  139. if (
  140. !individualSubscription ||
  141. individualSubscription.groupPlan ||
  142. isStandaloneAiAddOnPlanCode(individualSubscription.planCode)
  143. ) {
  144. return false
  145. }
  146. return Boolean(
  147. paymentRecord?.subscription?.addOns?.some(
  148. addOn => addOn.code === AI_ADD_ON_CODE
  149. )
  150. )
  151. }
  152. function getAiPlanType(
  153. bestSubscription,
  154. individualSubscription,
  155. paymentRecord,
  156. writefullData
  157. ) {
  158. if (
  159. bestSubscription?.type === 'standalone-ai-add-on' ||
  160. isStandaloneAiAddOnPlanCode(individualSubscription?.planCode)
  161. ) {
  162. return 'ai-assist-standalone'
  163. }
  164. if (hasIndividualAiAssistAddOn(individualSubscription, paymentRecord)) {
  165. return 'ai-assist-add-on'
  166. }
  167. if (writefullData?.isPremium) {
  168. return 'writefull-premium'
  169. }
  170. return 'none'
  171. }
  172. function getAiPlanCadence(
  173. aiPlan,
  174. bestSubscription,
  175. individualSubscription,
  176. paymentRecord
  177. ) {
  178. if (aiPlan === 'ai-assist-standalone') {
  179. return (
  180. getPlanCadenceFromPlanCode(individualSubscription?.planCode) ||
  181. getPlanCadenceFromPlanCode(paymentRecord?.subscription?.planCode)
  182. )
  183. }
  184. if (aiPlan === 'ai-assist-add-on') {
  185. return (
  186. getPlanCadence(bestSubscription) ||
  187. getPlanCadenceFromPlanCode(individualSubscription?.planCode)
  188. )
  189. }
  190. return null
  191. }
  192. function hasPlanAiEnabled(plan) {
  193. if (!plan?.features) {
  194. return false
  195. }
  196. return (
  197. plan.features.aiUsageQuota === Settings.aiFeatures.unlimitedQuota ||
  198. plan.features.aiErrorAssistant === true
  199. )
  200. }
  201. function getGroupAiEnabled(
  202. memberGroupSubscriptions = [],
  203. managedGroupSubscriptions = [],
  204. userIsMemberOfGroupSubscription
  205. ) {
  206. if (!userIsMemberOfGroupSubscription) {
  207. return null
  208. }
  209. const allGroupSubscriptions = [
  210. ...memberGroupSubscriptions,
  211. ...managedGroupSubscriptions,
  212. ]
  213. return allGroupSubscriptions.some(subscription => {
  214. const plan = Settings.plans.find(
  215. candidate => candidate.planCode === subscription.planCode
  216. )
  217. return hasPlanAiEnabled(plan)
  218. })
  219. }
  220. function getGroupSize(
  221. bestSubscription,
  222. memberGroupSubscriptions = [],
  223. managedGroupSubscriptions = []
  224. ) {
  225. const allGroupSubscriptions = [
  226. ...memberGroupSubscriptions,
  227. ...managedGroupSubscriptions,
  228. ]
  229. if (allGroupSubscriptions.length === 0) {
  230. return null
  231. }
  232. const matchingBestGroupSubscription =
  233. bestSubscription?.type === 'group'
  234. ? allGroupSubscriptions.find(
  235. subscription =>
  236. subscription.planCode === bestSubscription.plan?.planCode &&
  237. subscription.teamName === bestSubscription.subscription?.teamName
  238. )
  239. : null
  240. if (matchingBestGroupSubscription?.membersLimit != null) {
  241. return matchingBestGroupSubscription.membersLimit
  242. }
  243. if (bestSubscription?.subscription?.membersLimit != null) {
  244. return bestSubscription.subscription.membersLimit
  245. }
  246. if (
  247. bestSubscription?.plan?.groupPlan &&
  248. bestSubscription.plan.membersLimit != null
  249. ) {
  250. return bestSubscription.plan.membersLimit
  251. }
  252. return allGroupSubscriptions.reduce((largestGroupSize, subscription) => {
  253. const plan = Settings.plans.find(
  254. candidate => candidate.planCode === subscription.planCode
  255. )
  256. const groupSize = subscription.membersLimit ?? plan?.membersLimit ?? 0
  257. return Math.max(largestGroupSize, groupSize)
  258. }, 0)
  259. }
  260. function getPaymentProvider(
  261. individualSubscription,
  262. memberGroupSubscriptions = [],
  263. managedGroupSubscriptions = []
  264. ) {
  265. const candidates = [
  266. individualSubscription,
  267. ...memberGroupSubscriptions,
  268. ...managedGroupSubscriptions,
  269. ].filter(Boolean)
  270. if (candidates.length === 0) {
  271. return null
  272. }
  273. for (const candidate of candidates) {
  274. const service = candidate.paymentProvider?.service
  275. if (service) {
  276. return service.includes('stripe') ? 'stripe' : 'recurly'
  277. }
  278. }
  279. return 'recurly'
  280. }
  281. function shouldUseCommonsBestSubscription(
  282. hasCommons,
  283. bestSubscription,
  284. commonsPlan
  285. ) {
  286. if (!hasCommons) {
  287. return false
  288. }
  289. if (bestSubscription == null) {
  290. return true
  291. }
  292. return FeaturesHelper.isFeatureSetBetter(
  293. commonsPlan?.features || {},
  294. bestSubscription.plan?.features || {}
  295. )
  296. }
  297. /**
  298. * Compute plan-related user properties for sending to customer.io.
  299. */
  300. function getPlanProperties({
  301. bestSubscription,
  302. individualSubscription,
  303. individualPaymentRecord,
  304. memberGroupSubscriptions,
  305. managedGroupSubscriptions,
  306. userIsMemberOfGroupSubscription,
  307. hasCommons,
  308. writefullData,
  309. }) {
  310. const planType = normalizePlanType(bestSubscription)
  311. const displayPlanType = getFriendlyPlanName(planType)
  312. const planTermLabel = getPlanCadence(bestSubscription)
  313. const aiPlan = getAiPlanType(
  314. bestSubscription,
  315. individualSubscription,
  316. individualPaymentRecord,
  317. writefullData
  318. )
  319. const aiPlanTermLabel = getAiPlanCadence(
  320. aiPlan,
  321. bestSubscription,
  322. individualSubscription,
  323. individualPaymentRecord
  324. )
  325. const groupAiEnabled = getGroupAiEnabled(
  326. memberGroupSubscriptions,
  327. managedGroupSubscriptions,
  328. userIsMemberOfGroupSubscription
  329. )
  330. const nextRenewalDate = getNextRenewalDateFromPaymentRecord(
  331. individualPaymentRecord
  332. )
  333. const expiryDate = getExpiryDateFromPaymentRecord(individualPaymentRecord)
  334. const groupSizeValue = getGroupSize(
  335. bestSubscription,
  336. memberGroupSubscriptions,
  337. managedGroupSubscriptions
  338. )
  339. const nextRenewalDateTrait =
  340. nextRenewalDate ??
  341. (shouldClearNextRenewalDate(individualSubscription) ? '' : undefined)
  342. const expiryDateTrait =
  343. expiryDate ??
  344. (shouldClearExpiryDate(individualSubscription) ? '' : undefined)
  345. const trialEndDate = getTrialEndDate(individualSubscription)
  346. const properties = {
  347. ai_plan: aiPlan,
  348. group: userIsMemberOfGroupSubscription,
  349. commons: Boolean(hasCommons),
  350. individual_subscription: Boolean(
  351. individualSubscription && !individualSubscription.groupPlan
  352. ),
  353. }
  354. if (trialEndDate != null) properties.trial_end_date = trialEndDate
  355. const paymentProvider = getPaymentProvider(
  356. individualSubscription,
  357. memberGroupSubscriptions,
  358. managedGroupSubscriptions
  359. )
  360. if (paymentProvider) properties.payment_provider = paymentProvider
  361. if (planType) properties.plan_type = planType
  362. if (displayPlanType) properties.display_plan_type = displayPlanType
  363. if (planTermLabel) properties.plan_term_label = planTermLabel
  364. if (aiPlanTermLabel) properties.ai_plan_term_label = aiPlanTermLabel
  365. if (groupAiEnabled !== null) properties.group_ai_enabled = groupAiEnabled
  366. if (nextRenewalDateTrait !== undefined)
  367. properties.next_renewal_date = nextRenewalDateTrait
  368. if (expiryDateTrait !== undefined) properties.expiry_date = expiryDateTrait
  369. if (groupSizeValue !== null) properties.group_size = groupSizeValue
  370. return properties
  371. }
  372. export default {
  373. normalizePlanType,
  374. getFriendlyPlanName,
  375. getNextRenewalDateFromPaymentRecord,
  376. getExpiryDateFromPaymentRecord,
  377. getAiPlanType,
  378. getAiPlanCadence,
  379. hasPlanAiEnabled,
  380. shouldUseCommonsBestSubscription,
  381. getPlanProperties,
  382. }