SubscriptionHandler.mjs 11 KB

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