CustomerIoPlanHelpers.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660
  1. // @ts-check
  2. import Settings from '@overleaf/settings'
  3. import { AI_ADD_ON_CODE, isStandaloneAiAddOnPlanCode } from './AiHelper.mjs'
  4. import FeaturesHelper from './FeaturesHelper.mjs'
  5. /**
  6. * @typedef {InstanceType<typeof import('../../models/Subscription.mjs').Subscription>} MongoSubscription
  7. * @typedef {import('../../../../types/subscription/plan').Plan} Plan
  8. * @typedef {import('../../../../modules/subscriptions/app/src/PaymentService.mjs').PaymentRecord} PaymentRecord
  9. */
  10. /**
  11. * @template T
  12. * @typedef {T | null} Nullable
  13. */
  14. /**
  15. * Subset of the "best subscription" object from
  16. * SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel
  17. *
  18. * @typedef {object} BestSubscription
  19. * @property {'free' | 'individual' | 'group' | 'commons' | 'standalone-ai-add-on'} [type]
  20. * @property {Partial<Plan>} [plan]
  21. * @property {{ teamName?: string, membersLimit?: number }} [subscription]
  22. * @property {number} [remainingTrialDays]
  23. */
  24. const INACTIVE_NEXT_RENEWAL_DATE_STATES = new Set([
  25. 'canceled',
  26. 'cancelled',
  27. 'expired',
  28. ])
  29. const PENDING_CANCELLATION_STATES = new Set(['canceled', 'cancelled'])
  30. /**
  31. * @param {Nullable<MongoSubscription>} [subscription]
  32. * @returns {string}
  33. */
  34. function getSubscriptionState(subscription) {
  35. return (
  36. subscription?.recurlyStatus?.state ||
  37. subscription?.paymentProvider?.state ||
  38. ''
  39. )
  40. }
  41. /**
  42. * @param {Nullable<Date | string | number>} [dateValue]
  43. * @returns {number | null}
  44. */
  45. function toUnixTimestamp(dateValue) {
  46. if (!dateValue) {
  47. return null
  48. }
  49. const date = new Date(dateValue)
  50. if (Number.isNaN(date.getTime())) {
  51. return null
  52. }
  53. return Math.floor(date.getTime() / 1000)
  54. }
  55. /**
  56. * @param {Nullable<BestSubscription>} [bestSubscription]
  57. * @returns {string}
  58. */
  59. function normalizePlanType(bestSubscription) {
  60. if (!bestSubscription) {
  61. return ''
  62. }
  63. if (
  64. bestSubscription.type === 'standalone-ai-add-on' ||
  65. bestSubscription.type === 'commons'
  66. ) {
  67. return bestSubscription.type
  68. }
  69. const planCode = bestSubscription.plan?.planCode
  70. const isGroupPlan = bestSubscription.plan?.groupPlan === true
  71. if (!planCode) {
  72. return bestSubscription.type || ''
  73. }
  74. if (planCode.startsWith('v1_')) {
  75. return 'v1'
  76. }
  77. if (planCode.includes('student')) {
  78. return 'student'
  79. }
  80. if (planCode.includes('professional')) {
  81. return isGroupPlan ? 'group-professional' : 'professional'
  82. }
  83. if (planCode.includes('collaborator')) {
  84. return isGroupPlan ? 'group-standard' : 'standard'
  85. }
  86. if (planCode.includes('personal')) {
  87. return 'personal'
  88. }
  89. if (isGroupPlan) {
  90. return 'group-standard'
  91. }
  92. return planCode
  93. }
  94. /**
  95. * @param {Nullable<string>} [planCode]
  96. * @returns {string}
  97. */
  98. function normalizePlanTypeFromPlanCode(planCode) {
  99. if (!planCode) {
  100. return ''
  101. }
  102. const plan = /** @type {Plan[]} */ (Settings.plans).find(
  103. candidate => candidate.planCode === planCode
  104. )
  105. return normalizePlanType({
  106. plan: {
  107. planCode,
  108. groupPlan: plan?.groupPlan === true,
  109. },
  110. })
  111. }
  112. /**
  113. * @param {Nullable<string>} [planType]
  114. * @returns {string}
  115. */
  116. function getFriendlyPlanName(planType) {
  117. if (!planType) {
  118. return ''
  119. }
  120. /** @type {Record<string, string>} */
  121. const friendlyPlanNames = {
  122. free: 'Free',
  123. personal: 'Personal',
  124. standard: 'Standard',
  125. professional: 'Pro',
  126. student: 'Student',
  127. commons: 'Commons',
  128. 'group-standard': 'Group Standard',
  129. 'group-professional': 'Group Pro',
  130. 'standalone-ai-add-on': 'AI Assist add-on',
  131. v1: 'Legacy',
  132. }
  133. if (friendlyPlanNames[planType]) {
  134. return friendlyPlanNames[planType]
  135. }
  136. return planType
  137. }
  138. /**
  139. * @param {Nullable<BestSubscription>} [bestSubscription]
  140. * @returns {'annual' | 'monthly' | null}
  141. */
  142. function getPlanCadence(bestSubscription) {
  143. if (!bestSubscription?.plan) {
  144. return null
  145. }
  146. return bestSubscription.plan.annual ? 'annual' : 'monthly'
  147. }
  148. /**
  149. * @param {Nullable<string>} [planCode]
  150. * @returns {'annual' | 'monthly' | null}
  151. */
  152. function getPlanCadenceFromPlanCode(planCode) {
  153. if (!planCode) {
  154. return null
  155. }
  156. const plan = /** @type {Plan[]} */ (Settings.plans).find(
  157. candidate => candidate.planCode === planCode
  158. )
  159. if (plan) {
  160. return plan.annual ? 'annual' : 'monthly'
  161. }
  162. if (planCode.includes('annual')) {
  163. return 'annual'
  164. }
  165. if (isStandaloneAiAddOnPlanCode(planCode)) {
  166. return 'monthly'
  167. }
  168. return null
  169. }
  170. /**
  171. * @param {Nullable<PaymentRecord>} [paymentRecord]
  172. * @returns {number | null}
  173. */
  174. function getNextRenewalDateFromPaymentRecord(paymentRecord) {
  175. const subscriptionState = paymentRecord?.subscription?.state
  176. if (
  177. subscriptionState &&
  178. INACTIVE_NEXT_RENEWAL_DATE_STATES.has(subscriptionState)
  179. ) {
  180. return null
  181. }
  182. return toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
  183. }
  184. /**
  185. * @param {Nullable<MongoSubscription>} [subscription]
  186. * @returns {boolean}
  187. */
  188. function shouldClearNextRenewalDate(subscription) {
  189. if (!subscription) {
  190. return true
  191. }
  192. return INACTIVE_NEXT_RENEWAL_DATE_STATES.has(
  193. getSubscriptionState(subscription)
  194. )
  195. }
  196. /**
  197. * @param {Nullable<PaymentRecord>} [paymentRecord]
  198. * @returns {number | null}
  199. */
  200. function getExpiryDateFromPaymentRecord(paymentRecord) {
  201. const subscriptionState = paymentRecord?.subscription?.state
  202. if (
  203. subscriptionState == null ||
  204. !PENDING_CANCELLATION_STATES.has(subscriptionState)
  205. ) {
  206. return null
  207. }
  208. const expiryDate = toUnixTimestamp(paymentRecord?.subscription?.periodEnd)
  209. if (expiryDate == null) {
  210. return null
  211. }
  212. return expiryDate > Math.floor(Date.now() / 1000) ? expiryDate : null
  213. }
  214. /**
  215. * @param {Nullable<MongoSubscription>} [subscription]
  216. * @returns {boolean}
  217. */
  218. function shouldClearExpiryDate(subscription) {
  219. if (!subscription) {
  220. return true
  221. }
  222. return !PENDING_CANCELLATION_STATES.has(getSubscriptionState(subscription))
  223. }
  224. /**
  225. * @param {Nullable<MongoSubscription>} [individualSubscription]
  226. * @returns {number | null}
  227. */
  228. function getTrialEndDate(individualSubscription) {
  229. const trialEndsAt =
  230. individualSubscription?.recurlyStatus?.trialEndsAt ||
  231. individualSubscription?.paymentProvider?.trialEndsAt
  232. return toUnixTimestamp(trialEndsAt)
  233. }
  234. /**
  235. * @param {Nullable<MongoSubscription>} [individualSubscription]
  236. * @param {Nullable<PaymentRecord>} [paymentRecord]
  237. * @returns {boolean}
  238. */
  239. function hasIndividualAiAssistAddOn(individualSubscription, paymentRecord) {
  240. if (
  241. !individualSubscription ||
  242. individualSubscription.groupPlan ||
  243. isStandaloneAiAddOnPlanCode(individualSubscription.planCode)
  244. ) {
  245. return false
  246. }
  247. return Boolean(
  248. paymentRecord?.subscription?.addOns?.some(
  249. addOn => addOn.code === AI_ADD_ON_CODE
  250. )
  251. )
  252. }
  253. /**
  254. * @param {Nullable<BestSubscription>} [bestSubscription]
  255. * @param {Nullable<MongoSubscription>} [individualSubscription]
  256. * @param {Nullable<PaymentRecord>} [paymentRecord]
  257. * @param {Nullable<{ isPremium?: boolean }>} [writefullData]
  258. * @returns {'ai-assist-standalone' | 'ai-assist-add-on' | 'writefull-premium' | 'none'}
  259. */
  260. function getAiPlanType(
  261. bestSubscription,
  262. individualSubscription,
  263. paymentRecord,
  264. writefullData
  265. ) {
  266. if (
  267. bestSubscription?.type === 'standalone-ai-add-on' ||
  268. isStandaloneAiAddOnPlanCode(individualSubscription?.planCode)
  269. ) {
  270. return 'ai-assist-standalone'
  271. }
  272. if (hasIndividualAiAssistAddOn(individualSubscription, paymentRecord)) {
  273. return 'ai-assist-add-on'
  274. }
  275. if (writefullData?.isPremium) {
  276. return 'writefull-premium'
  277. }
  278. return 'none'
  279. }
  280. /**
  281. * @param {Nullable<string>} [aiPlan]
  282. * @param {Nullable<BestSubscription>} [bestSubscription]
  283. * @param {Nullable<MongoSubscription>} [individualSubscription]
  284. * @param {Nullable<PaymentRecord>} [paymentRecord]
  285. * @returns {'annual' | 'monthly' | null}
  286. */
  287. function getAiPlanCadence(
  288. aiPlan,
  289. bestSubscription,
  290. individualSubscription,
  291. paymentRecord
  292. ) {
  293. if (aiPlan === 'ai-assist-standalone') {
  294. return (
  295. getPlanCadenceFromPlanCode(individualSubscription?.planCode) ||
  296. getPlanCadenceFromPlanCode(paymentRecord?.subscription?.planCode)
  297. )
  298. }
  299. if (aiPlan === 'ai-assist-add-on') {
  300. return (
  301. getPlanCadence(bestSubscription) ||
  302. getPlanCadenceFromPlanCode(individualSubscription?.planCode)
  303. )
  304. }
  305. return null
  306. }
  307. /**
  308. * @param {Nullable<Partial<Plan>>} [plan]
  309. * @returns {boolean}
  310. */
  311. function hasPlanAiEnabled(plan) {
  312. if (!plan?.features) {
  313. return false
  314. }
  315. return (
  316. plan.features.aiUsageQuota === Settings.aiFeatures.unlimitedQuota ||
  317. plan.features.aiErrorAssistant === true
  318. )
  319. }
  320. /**
  321. * @param {MongoSubscription[]} [memberGroupSubscriptions]
  322. * @param {MongoSubscription[]} [managedGroupSubscriptions]
  323. * @param {boolean} [userIsMemberOfGroupSubscription]
  324. * @param {Map<string, boolean>} [aiBlockedByPolicyId]
  325. * @returns {boolean | null}
  326. */
  327. function getGroupAiEnabled(
  328. memberGroupSubscriptions = [],
  329. managedGroupSubscriptions = [],
  330. userIsMemberOfGroupSubscription,
  331. aiBlockedByPolicyId = new Map()
  332. ) {
  333. if (!userIsMemberOfGroupSubscription) {
  334. return null
  335. }
  336. const allGroupSubscriptions = [
  337. ...memberGroupSubscriptions,
  338. ...managedGroupSubscriptions,
  339. ]
  340. const someBlocked = allGroupSubscriptions.some(subscription => {
  341. const policyId = subscription.groupPolicy?.toString()
  342. return policyId ? aiBlockedByPolicyId.get(policyId) : false
  343. })
  344. return !someBlocked
  345. }
  346. /**
  347. * @param {Nullable<BestSubscription>} [bestSubscription]
  348. * @param {MongoSubscription[]} [memberGroupSubscriptions]
  349. * @param {MongoSubscription[]} [managedGroupSubscriptions]
  350. * @returns {number | null}
  351. */
  352. function getGroupSize(
  353. bestSubscription,
  354. memberGroupSubscriptions = [],
  355. managedGroupSubscriptions = []
  356. ) {
  357. const allGroupSubscriptions = [
  358. ...memberGroupSubscriptions,
  359. ...managedGroupSubscriptions,
  360. ]
  361. if (allGroupSubscriptions.length === 0) {
  362. return null
  363. }
  364. const matchingBestGroupSubscription =
  365. bestSubscription?.type === 'group'
  366. ? allGroupSubscriptions.find(
  367. subscription =>
  368. subscription.planCode === bestSubscription.plan?.planCode &&
  369. subscription.teamName === bestSubscription.subscription?.teamName
  370. )
  371. : null
  372. if (matchingBestGroupSubscription?.membersLimit != null) {
  373. return matchingBestGroupSubscription.membersLimit
  374. }
  375. if (bestSubscription?.subscription?.membersLimit != null) {
  376. return bestSubscription.subscription.membersLimit
  377. }
  378. if (
  379. bestSubscription?.plan?.groupPlan &&
  380. bestSubscription.plan.membersLimit != null
  381. ) {
  382. return bestSubscription.plan.membersLimit
  383. }
  384. return allGroupSubscriptions.reduce((largestGroupSize, subscription) => {
  385. const plan = /** @type {Plan[]} */ (Settings.plans).find(
  386. candidate => candidate.planCode === subscription.planCode
  387. )
  388. const groupSize = subscription.membersLimit ?? plan?.membersLimit ?? 0
  389. return Math.max(largestGroupSize, groupSize)
  390. }, 0)
  391. }
  392. /**
  393. * @param {Nullable<MongoSubscription>} [individualSubscription]
  394. * @param {MongoSubscription[]} [memberGroupSubscriptions]
  395. * @param {MongoSubscription[]} [managedGroupSubscriptions]
  396. * @returns {'stripe' | 'recurly' | null}
  397. */
  398. function getPaymentProvider(
  399. individualSubscription,
  400. memberGroupSubscriptions = [],
  401. managedGroupSubscriptions = []
  402. ) {
  403. const candidates = /** @type {MongoSubscription[]} */ (
  404. [
  405. individualSubscription,
  406. ...memberGroupSubscriptions,
  407. ...managedGroupSubscriptions,
  408. ].filter(Boolean)
  409. )
  410. if (candidates.length === 0) {
  411. return null
  412. }
  413. for (const candidate of candidates) {
  414. const service = candidate.paymentProvider?.service
  415. if (service) {
  416. return service.includes('stripe') ? 'stripe' : 'recurly'
  417. }
  418. }
  419. return 'recurly'
  420. }
  421. /**
  422. * @param {boolean} hasCommons
  423. * @param {Nullable<BestSubscription>} [bestSubscription]
  424. * @param {Nullable<Partial<Plan>>} [commonsPlan]
  425. * @returns {boolean}
  426. */
  427. function shouldUseCommonsBestSubscription(
  428. hasCommons,
  429. bestSubscription,
  430. commonsPlan
  431. ) {
  432. if (!hasCommons) {
  433. return false
  434. }
  435. if (bestSubscription == null) {
  436. return true
  437. }
  438. return FeaturesHelper.isFeatureSetBetter(
  439. commonsPlan?.features || {},
  440. bestSubscription.plan?.features || {}
  441. )
  442. }
  443. /**
  444. * Determine the user's role in any group subscription they participate in.
  445. *
  446. * @param {MongoSubscription[]} memberGroupSubscriptions
  447. * @param {MongoSubscription[]} managedGroupSubscriptions
  448. * @param {string|object} userId
  449. * @returns {''|'admin'|'manager'|'member'}
  450. */
  451. function getGroupRole(
  452. memberGroupSubscriptions = [],
  453. managedGroupSubscriptions = [],
  454. userId
  455. ) {
  456. if (
  457. managedGroupSubscriptions.length === 0 &&
  458. memberGroupSubscriptions.length === 0
  459. ) {
  460. return ''
  461. }
  462. const userIdStr = userId.toString()
  463. const isAdmin = managedGroupSubscriptions.some(
  464. sub => sub.admin_id?._id?.toString() === userIdStr
  465. )
  466. if (isAdmin) return 'admin'
  467. if (managedGroupSubscriptions.length > 0) return 'manager'
  468. return 'member'
  469. }
  470. /**
  471. * Compute plan-related user properties for sending to customer.io.
  472. *
  473. * @param {object} options
  474. * @param {BestSubscription} options.bestSubscription
  475. * @param {Nullable<MongoSubscription>} [options.individualSubscription]
  476. * @param {Nullable<PaymentRecord>} [options.individualPaymentRecord]
  477. * @param {MongoSubscription[]} [options.memberGroupSubscriptions]
  478. * @param {MongoSubscription[]} [options.managedGroupSubscriptions]
  479. * @param {boolean} options.userIsMemberOfGroupSubscription
  480. * @param {boolean} options.hasCommons
  481. * @param {Nullable<{ isPremium?: boolean }>} [options.writefullData]
  482. * @param {Map<string, boolean>} [options.aiBlockedByPolicyId]
  483. * @param {string|object} options.userId
  484. */
  485. function getPlanProperties({
  486. bestSubscription,
  487. individualSubscription,
  488. individualPaymentRecord,
  489. memberGroupSubscriptions,
  490. managedGroupSubscriptions,
  491. userIsMemberOfGroupSubscription,
  492. hasCommons,
  493. writefullData,
  494. aiBlockedByPolicyId,
  495. userId,
  496. }) {
  497. const planType = normalizePlanType(bestSubscription)
  498. const displayPlanType = getFriendlyPlanName(planType)
  499. const planTermLabel = getPlanCadence(bestSubscription)
  500. const aiPlan = getAiPlanType(
  501. bestSubscription,
  502. individualSubscription,
  503. individualPaymentRecord,
  504. writefullData
  505. )
  506. const aiPlanTermLabel = getAiPlanCadence(
  507. aiPlan,
  508. bestSubscription,
  509. individualSubscription,
  510. individualPaymentRecord
  511. )
  512. const groupAiEnabled = getGroupAiEnabled(
  513. memberGroupSubscriptions,
  514. managedGroupSubscriptions,
  515. userIsMemberOfGroupSubscription,
  516. aiBlockedByPolicyId
  517. )
  518. const nextRenewalDate = getNextRenewalDateFromPaymentRecord(
  519. individualPaymentRecord
  520. )
  521. const expiryDate = getExpiryDateFromPaymentRecord(individualPaymentRecord)
  522. const groupSizeValue = getGroupSize(
  523. bestSubscription,
  524. memberGroupSubscriptions,
  525. managedGroupSubscriptions
  526. )
  527. const nextRenewalDateTrait =
  528. nextRenewalDate ??
  529. (shouldClearNextRenewalDate(individualSubscription) ? '' : undefined)
  530. const expiryDateTrait =
  531. expiryDate ??
  532. (shouldClearExpiryDate(individualSubscription) ? '' : undefined)
  533. const trialEndDate = getTrialEndDate(individualSubscription)
  534. /** @type {Record<string, unknown>} */
  535. const properties = {
  536. ai_plan: aiPlan,
  537. group: userIsMemberOfGroupSubscription,
  538. group_role: getGroupRole(
  539. memberGroupSubscriptions,
  540. managedGroupSubscriptions,
  541. userId
  542. ),
  543. commons: Boolean(hasCommons),
  544. individual_subscription: Boolean(
  545. individualSubscription && !individualSubscription.groupPlan
  546. ),
  547. past_due: getSubscriptionState(individualSubscription) === 'past_due',
  548. }
  549. if (trialEndDate != null) properties.trial_end_date = trialEndDate
  550. const paymentProvider = getPaymentProvider(
  551. individualSubscription,
  552. memberGroupSubscriptions,
  553. managedGroupSubscriptions
  554. )
  555. if (paymentProvider) properties.payment_provider = paymentProvider
  556. if (planType) properties.plan_type = planType
  557. if (displayPlanType) properties.display_plan_type = displayPlanType
  558. if (planTermLabel) properties.plan_term_label = planTermLabel
  559. if (aiPlanTermLabel) properties.ai_plan_term_label = aiPlanTermLabel
  560. if (groupAiEnabled !== null) properties.group_ai_enabled = groupAiEnabled
  561. if (nextRenewalDateTrait !== undefined)
  562. properties.next_renewal_date = nextRenewalDateTrait
  563. if (expiryDateTrait !== undefined) properties.expiry_date = expiryDateTrait
  564. if (groupSizeValue !== null) properties.group_size = groupSizeValue
  565. return properties
  566. }
  567. export default {
  568. normalizePlanType,
  569. normalizePlanTypeFromPlanCode,
  570. getFriendlyPlanName,
  571. getNextRenewalDateFromPaymentRecord,
  572. getExpiryDateFromPaymentRecord,
  573. getAiPlanType,
  574. getAiPlanCadence,
  575. hasPlanAiEnabled,
  576. shouldUseCommonsBestSubscription,
  577. getGroupRole,
  578. getPlanProperties,
  579. }