SubscriptionHandler.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. // @ts-check
  2. import RecurlyWrapper from './RecurlyWrapper.mjs'
  3. import { User } from '../../models/User.mjs'
  4. import logger from '@overleaf/logger'
  5. import SubscriptionHelper from './SubscriptionHelper.mjs'
  6. import SubscriptionUpdater from './SubscriptionUpdater.mjs'
  7. import LimitationsManager from './LimitationsManager.mjs'
  8. import EmailHandler from '../Email/EmailHandler.mjs'
  9. import { callbackify } from '@overleaf/promise-utils'
  10. import UserUpdater from '../User/UserUpdater.mjs'
  11. import Modules from '../../infrastructure/Modules.mjs'
  12. import { AI_ADD_ON_CODE } from './AiHelper.mjs'
  13. import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.mjs'
  14. /**
  15. * @import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
  16. */
  17. /**
  18. * @param {any} userId
  19. */
  20. async function validateNoSubscriptionInRecurly(userId) {
  21. let subscriptions =
  22. await RecurlyWrapper.promises.listAccountActiveSubscriptions(userId)
  23. if (!subscriptions) {
  24. subscriptions = []
  25. }
  26. if (subscriptions.length > 0) {
  27. await SubscriptionUpdater.promises.syncSubscription(
  28. subscriptions[0],
  29. userId
  30. )
  31. return false
  32. }
  33. return true
  34. }
  35. /**
  36. * @param {any} user
  37. * @param {any} subscriptionDetails
  38. * @param {any} recurlyTokenIds
  39. */
  40. async function createSubscription(user, subscriptionDetails, recurlyTokenIds) {
  41. const valid = await validateNoSubscriptionInRecurly(user._id)
  42. if (!valid) {
  43. throw new Error('user already has subscription in recurly')
  44. }
  45. const recurlySubscription = await RecurlyWrapper.promises.createSubscription(
  46. user,
  47. subscriptionDetails,
  48. recurlyTokenIds
  49. )
  50. if (recurlySubscription.trial_started_at) {
  51. const trialStartedAt = new Date(recurlySubscription.trial_started_at)
  52. await UserUpdater.promises.updateUser(
  53. { _id: user._id, lastTrial: { $not: { $gt: trialStartedAt } } },
  54. { $set: { lastTrial: trialStartedAt } }
  55. )
  56. }
  57. await SubscriptionUpdater.promises.syncSubscription(
  58. recurlySubscription,
  59. user._id
  60. )
  61. }
  62. /**
  63. * Preview the effect of changing the subscription plan
  64. *
  65. * @param {string} userId
  66. * @param {string} planCode
  67. * @return {Promise<PaymentProviderSubscriptionChange>}
  68. */
  69. async function previewSubscriptionChange(userId, planCode) {
  70. const change = await Modules.promises.hooks.fire(
  71. 'previewPlanChange',
  72. userId,
  73. planCode
  74. )
  75. return change[0]
  76. }
  77. /**
  78. * @param user
  79. * @param {any} user
  80. * @param {any} planCode
  81. */
  82. async function updateSubscription(user, planCode) {
  83. let hasSubscription = false
  84. let subscription
  85. try {
  86. ;({ hasSubscription, subscription } =
  87. await LimitationsManager.promises.userHasSubscription(user))
  88. } catch (err) {
  89. logger.warn(
  90. { err, userId: user._id },
  91. 'there was an error checking user v2 subscription'
  92. )
  93. }
  94. if (
  95. !hasSubscription ||
  96. subscription == null ||
  97. SubscriptionHelper.getPaymentProviderSubscriptionId(subscription) == null
  98. ) {
  99. return
  100. }
  101. const previousPlanType = CustomerIoPlanHelpers.normalizePlanType({
  102. plan: {
  103. planCode: subscription.planCode,
  104. groupPlan: subscription.groupPlan,
  105. },
  106. })
  107. await Modules.promises.hooks.fire(
  108. 'updatePaidSubscription',
  109. subscription,
  110. planCode,
  111. user._id
  112. )
  113. if (previousPlanType) {
  114. Modules.promises.hooks
  115. .fire('setUserProperties', user._id, {
  116. previous_plan_type: previousPlanType,
  117. })
  118. .catch(err => {
  119. logger.warn(
  120. { err, userId: user._id },
  121. 'Failed to set previous_plan_type in customer.io'
  122. )
  123. })
  124. }
  125. }
  126. /**
  127. * @param {any} user
  128. */
  129. async function cancelPendingSubscriptionChange(user) {
  130. const { hasSubscription, subscription } =
  131. await LimitationsManager.promises.userHasSubscription(user)
  132. if (hasSubscription && subscription != null) {
  133. const [paymentRecord] = await Modules.promises.hooks.fire(
  134. 'getPaymentFromRecord',
  135. subscription
  136. )
  137. if (paymentRecord != null) {
  138. const changeRequest =
  139. paymentRecord.subscription.getRequestForPlanChangeCancellation()
  140. if (changeRequest) {
  141. // There are pending add-on changes to preserve, apply the change request
  142. await Modules.promises.hooks.fire(
  143. 'applySubscriptionChangeRequestAndSync',
  144. changeRequest,
  145. user._id.toString()
  146. )
  147. } else if (paymentRecord.subscription.pendingChange != null) {
  148. // No add-on changes to preserve, just remove the pending change
  149. await Modules.promises.hooks.fire(
  150. 'cancelPendingPaidSubscriptionChange',
  151. subscription
  152. )
  153. }
  154. }
  155. }
  156. }
  157. /**
  158. * Send cancellation email to user with split test for AI Assist addon
  159. * @param {any} user
  160. */
  161. async function _sendCancellationEmail(user) {
  162. const emailOpts = {
  163. to: user.email,
  164. first_name: user.first_name,
  165. }
  166. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  167. logger.debug(
  168. { userId: user._id },
  169. 'deferred email: canceledSubscriptionOrAddOn'
  170. )
  171. EmailHandler.sendDeferredEmail(
  172. 'canceledSubscriptionOrAddOn',
  173. emailOpts,
  174. ONE_HOUR_IN_MS
  175. )
  176. }
  177. /**
  178. * @param {any} user
  179. */
  180. async function cancelSubscription(user) {
  181. const { hasSubscription, subscription } =
  182. await LimitationsManager.promises.userHasSubscription(user)
  183. if (hasSubscription && subscription != null) {
  184. await Modules.promises.hooks.fire('cancelPaidSubscription', subscription)
  185. await _sendCancellationEmail(user)
  186. }
  187. }
  188. /**
  189. * @param {any} user
  190. */
  191. async function reactivateSubscription(user) {
  192. try {
  193. const { hasSubscription, subscription } =
  194. await LimitationsManager.promises.userHasSubscription(user)
  195. if (hasSubscription && subscription != null) {
  196. await Modules.promises.hooks.fire(
  197. 'reactivatePaidSubscription',
  198. subscription
  199. )
  200. EmailHandler.sendEmail(
  201. 'reactivatedSubscription',
  202. { to: user.email },
  203. err => {
  204. if (err) {
  205. logger.warn(
  206. { err },
  207. 'failed to send reactivation confirmation email'
  208. )
  209. }
  210. }
  211. )
  212. }
  213. } catch (err) {
  214. logger.warn(
  215. { err, userId: user._id },
  216. 'there was an error checking user v2 subscription'
  217. )
  218. }
  219. }
  220. /**
  221. * @param {any} recurlySubscription
  222. * @param {any} requesterData
  223. */
  224. async function syncSubscription(recurlySubscription, requesterData) {
  225. const storedSubscription = await RecurlyWrapper.promises.getSubscription(
  226. recurlySubscription.uuid,
  227. { includeAccount: true }
  228. )
  229. const user = await User.findById(storedSubscription.account.account_code, {
  230. _id: 1,
  231. }).exec()
  232. if (!user) {
  233. throw new Error('no user found')
  234. }
  235. await SubscriptionUpdater.promises.syncSubscription(
  236. storedSubscription,
  237. user._id,
  238. requesterData
  239. )
  240. }
  241. /**
  242. * attempt to collect past due invoice for customer. Only do that when a) the
  243. * customer is using Paypal and b) there is only one past due invoice.
  244. * This is used because Recurly doesn't always attempt collection of paast due
  245. * invoices after Paypal billing info were updated.
  246. *
  247. * @param {any} recurlyAccountCode
  248. */
  249. async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
  250. const billingInfo =
  251. await RecurlyWrapper.promises.getBillingInfo(recurlyAccountCode)
  252. if (!billingInfo.paypal_billing_agreement_id) {
  253. // this is not a Paypal user
  254. return
  255. }
  256. const pastDueInvoices =
  257. await RecurlyWrapper.promises.getAccountPastDueInvoices(recurlyAccountCode)
  258. if (pastDueInvoices.length !== 1) {
  259. // no past due invoices, or more than one. Ignore.
  260. return
  261. }
  262. return await RecurlyWrapper.promises.attemptInvoiceCollection(
  263. pastDueInvoices[0].invoice_number
  264. )
  265. }
  266. /**
  267. * @param {any} subscription
  268. * @param {any} daysToExtend
  269. */
  270. async function extendTrial(subscription, daysToExtend) {
  271. await Modules.promises.hooks.fire('extendTrial', subscription, daysToExtend)
  272. }
  273. /**
  274. * Preview the effect of purchasing an add-on
  275. *
  276. * @param {string} userId
  277. * @param {string} addOnCode
  278. * @return {Promise<PaymentProviderSubscriptionChange>}
  279. */
  280. async function previewAddonPurchase(userId, addOnCode) {
  281. const change = await Modules.promises.hooks.fire(
  282. 'previewAddOnPurchase',
  283. userId,
  284. addOnCode
  285. )
  286. return change[0]
  287. }
  288. /**
  289. * Purchase an add-on for a user
  290. *
  291. * @param {string} userId
  292. * @param {string} addOnCode
  293. * @param {number} quantity
  294. */
  295. async function purchaseAddon(userId, addOnCode, quantity) {
  296. await Modules.promises.hooks.fire(
  297. 'purchaseAddOn',
  298. userId,
  299. addOnCode,
  300. quantity
  301. )
  302. }
  303. /**
  304. * Cancels an add-on for a user
  305. *
  306. * @param {any} user
  307. * @param {string} addOnCode
  308. */
  309. async function removeAddon(user, addOnCode) {
  310. await Modules.promises.hooks.fire('removeAddOn', user._id, addOnCode)
  311. if (addOnCode === AI_ADD_ON_CODE) {
  312. await _sendCancellationEmail(user)
  313. }
  314. }
  315. /**
  316. * Reactivates an add-on pending cancellation
  317. *
  318. * @param {string} userId
  319. * @param {string} addOnCode
  320. */
  321. async function reactivateAddon(userId, addOnCode) {
  322. await Modules.promises.hooks.fire('reactivateAddOn', userId, addOnCode)
  323. }
  324. /**
  325. * @param {any} user
  326. * @param {any} pauseCycles
  327. */
  328. async function pauseSubscription(user, pauseCycles) {
  329. // only allow pausing on monthly plans not in a trial
  330. const { subscription } =
  331. await LimitationsManager.promises.userHasSubscription(user)
  332. if (
  333. !subscription ||
  334. !SubscriptionHelper.getPaidSubscriptionState(subscription)
  335. ) {
  336. throw new Error('No active subscription to pause')
  337. }
  338. if (
  339. !subscription.planCode ||
  340. subscription.planCode.includes('ann') ||
  341. subscription.groupPlan
  342. ) {
  343. throw new Error('Can only pause monthly individual plans')
  344. }
  345. const trialEndsAt =
  346. SubscriptionHelper.getSubscriptionTrialEndsAt(subscription)
  347. if (trialEndsAt && trialEndsAt > new Date()) {
  348. throw new Error('Cannot pause a subscription in a trial')
  349. }
  350. if (subscription.addOns?.length) {
  351. throw new Error('Cannot pause a subscription with addons')
  352. }
  353. await Modules.promises.hooks.fire(
  354. 'pausePaidSubscription',
  355. subscription,
  356. pauseCycles
  357. )
  358. }
  359. /**
  360. * @param {any} user
  361. */ async function resumeSubscription(user) {
  362. const { subscription } =
  363. await LimitationsManager.promises.userHasSubscription(user)
  364. if (
  365. !subscription ||
  366. !SubscriptionHelper.getPaidSubscriptionState(subscription)
  367. ) {
  368. throw new Error('No active subscription to resume')
  369. }
  370. await Modules.promises.hooks.fire('resumePaidSubscription', subscription)
  371. }
  372. export default {
  373. validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
  374. createSubscription: callbackify(createSubscription),
  375. previewSubscriptionChange: callbackify(previewSubscriptionChange),
  376. updateSubscription: callbackify(updateSubscription),
  377. cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange),
  378. cancelSubscription: callbackify(cancelSubscription),
  379. reactivateSubscription: callbackify(reactivateSubscription),
  380. syncSubscription: callbackify(syncSubscription),
  381. attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
  382. extendTrial: callbackify(extendTrial),
  383. previewAddonPurchase: callbackify(previewAddonPurchase),
  384. purchaseAddon: callbackify(purchaseAddon),
  385. removeAddon: callbackify(removeAddon),
  386. reactivateAddon: callbackify(reactivateAddon),
  387. pauseSubscription: callbackify(pauseSubscription),
  388. resumeSubscription: callbackify(resumeSubscription),
  389. promises: {
  390. validateNoSubscriptionInRecurly,
  391. createSubscription,
  392. previewSubscriptionChange,
  393. updateSubscription,
  394. cancelPendingSubscriptionChange,
  395. cancelSubscription,
  396. reactivateSubscription,
  397. syncSubscription,
  398. attemptPaypalInvoiceCollection,
  399. extendTrial,
  400. previewAddonPurchase,
  401. purchaseAddon,
  402. removeAddon,
  403. reactivateAddon,
  404. pauseSubscription,
  405. resumeSubscription,
  406. },
  407. }