SubscriptionHandler.js 12 KB

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