SubscriptionHandler.mjs 13 KB

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