SubscriptionHandler.mjs 11 KB

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