SubscriptionHandler.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. // @ts-check
  2. const recurly = require('recurly')
  3. const RecurlyWrapper = require('./RecurlyWrapper')
  4. const RecurlyClient = require('./RecurlyClient')
  5. const { User } = require('../../models/User')
  6. const logger = require('@overleaf/logger')
  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 { NotFoundError } = require('../Errors/Errors')
  14. /**
  15. * @import { RecurlySubscription, RecurlySubscriptionChange } from './RecurlyEntities'
  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<RecurlySubscriptionChange>}
  60. */
  61. async function previewSubscriptionChange(userId, planCode) {
  62. const subscription = await getSubscriptionForUser(userId)
  63. const changeRequest = subscription?.getRequestForPlanChange(planCode)
  64. const change =
  65. await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
  66. return change
  67. }
  68. /**
  69. * @param user
  70. * @param planCode
  71. * @param couponCode
  72. */
  73. async function updateSubscription(user, planCode, couponCode) {
  74. let hasSubscription = false
  75. let subscription
  76. try {
  77. ;({ hasSubscription, subscription } =
  78. await LimitationsManager.promises.userHasV2Subscription(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. subscription.recurlySubscription_id == null
  89. ) {
  90. return
  91. }
  92. const recurlySubscriptionId = subscription.recurlySubscription_id
  93. if (couponCode) {
  94. const usersSubscription = await RecurlyWrapper.promises.getSubscription(
  95. recurlySubscriptionId,
  96. { includeAccount: true }
  97. )
  98. await RecurlyWrapper.promises.redeemCoupon(
  99. usersSubscription.account.account_code,
  100. couponCode
  101. )
  102. }
  103. const recurlySubscription = await RecurlyClient.promises.getSubscription(
  104. recurlySubscriptionId
  105. )
  106. const changeRequest = recurlySubscription.getRequestForPlanChange(planCode)
  107. await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
  108. await syncSubscription({ uuid: recurlySubscriptionId }, user._id)
  109. }
  110. /**
  111. * @param user
  112. */
  113. async function cancelPendingSubscriptionChange(user) {
  114. const { hasSubscription, subscription } =
  115. await LimitationsManager.promises.userHasV2Subscription(user)
  116. if (hasSubscription && subscription != null) {
  117. await RecurlyClient.promises.removeSubscriptionChangeByUuid(
  118. subscription.recurlySubscription_id
  119. )
  120. }
  121. }
  122. /**
  123. * @param user
  124. */
  125. async function cancelSubscription(user) {
  126. try {
  127. const { hasSubscription, subscription } =
  128. await LimitationsManager.promises.userHasV2Subscription(user)
  129. if (hasSubscription && subscription != null) {
  130. await RecurlyClient.promises.cancelSubscriptionByUuid(
  131. subscription.recurlySubscription_id
  132. )
  133. await _updateSubscriptionFromRecurly(subscription)
  134. const emailOpts = {
  135. to: user.email,
  136. first_name: user.first_name,
  137. }
  138. const ONE_HOUR_IN_MS = 1000 * 60 * 60
  139. EmailHandler.sendDeferredEmail(
  140. 'canceledSubscription',
  141. emailOpts,
  142. ONE_HOUR_IN_MS
  143. )
  144. }
  145. } catch (err) {
  146. logger.warn(
  147. { err, userId: user._id },
  148. 'there was an error checking user v2 subscription'
  149. )
  150. }
  151. }
  152. /**
  153. * @param user
  154. */
  155. async function reactivateSubscription(user) {
  156. try {
  157. const { hasSubscription, subscription } =
  158. await LimitationsManager.promises.userHasV2Subscription(user)
  159. if (hasSubscription && subscription != null) {
  160. await RecurlyClient.promises.reactivateSubscriptionByUuid(
  161. subscription.recurlySubscription_id
  162. )
  163. await _updateSubscriptionFromRecurly(subscription)
  164. EmailHandler.sendEmail(
  165. 'reactivatedSubscription',
  166. { to: user.email },
  167. err => {
  168. if (err) {
  169. logger.warn(
  170. { err },
  171. 'failed to send reactivation confirmation email'
  172. )
  173. }
  174. }
  175. )
  176. }
  177. } catch (err) {
  178. logger.warn(
  179. { err, userId: user._id },
  180. 'there was an error checking user v2 subscription'
  181. )
  182. }
  183. }
  184. /**
  185. * @param recurlySubscription
  186. * @param requesterData
  187. */
  188. async function syncSubscription(recurlySubscription, requesterData) {
  189. const storedSubscription = await RecurlyWrapper.promises.getSubscription(
  190. recurlySubscription.uuid,
  191. { includeAccount: true }
  192. )
  193. const user = await User.findById(storedSubscription.account.account_code, {
  194. _id: 1,
  195. }).exec()
  196. if (!user) {
  197. throw new Error('no user found')
  198. }
  199. await SubscriptionUpdater.promises.syncSubscription(
  200. storedSubscription,
  201. user._id,
  202. requesterData
  203. )
  204. }
  205. /**
  206. * attempt to collect past due invoice for customer. Only do that when a) the
  207. * customer is using Paypal and b) there is only one past due invoice.
  208. * This is used because Recurly doesn't always attempt collection of paast due
  209. * invoices after Paypal billing info were updated.
  210. *
  211. * @param recurlyAccountCode
  212. */
  213. async function attemptPaypalInvoiceCollection(recurlyAccountCode) {
  214. const billingInfo =
  215. await RecurlyWrapper.promises.getBillingInfo(recurlyAccountCode)
  216. if (!billingInfo.paypal_billing_agreement_id) {
  217. // this is not a Paypal user
  218. return
  219. }
  220. const pastDueInvoices =
  221. await RecurlyWrapper.promises.getAccountPastDueInvoices(recurlyAccountCode)
  222. if (pastDueInvoices.length !== 1) {
  223. // no past due invoices, or more than one. Ignore.
  224. return
  225. }
  226. return await RecurlyWrapper.promises.attemptInvoiceCollection(
  227. pastDueInvoices[0].invoice_number
  228. )
  229. }
  230. async function extendTrial(subscription, daysToExend) {
  231. await RecurlyWrapper.promises.extendTrial(
  232. subscription.recurlySubscription_id,
  233. daysToExend
  234. )
  235. }
  236. async function _updateSubscriptionFromRecurly(subscription) {
  237. const recurlySubscription = await RecurlyWrapper.promises.getSubscription(
  238. subscription.recurlySubscription_id,
  239. {}
  240. )
  241. await SubscriptionUpdater.promises.updateSubscriptionFromRecurly(
  242. recurlySubscription,
  243. subscription
  244. )
  245. }
  246. /**
  247. * Preview the effect of purchasing an add-on
  248. *
  249. * @param {string} userId
  250. * @param {string} addOnCode
  251. * @return {Promise<RecurlySubscriptionChange>}
  252. */
  253. async function previewAddonPurchase(userId, addOnCode) {
  254. const subscription = await getSubscriptionForUser(userId)
  255. try {
  256. await RecurlyClient.promises.getAddOn(subscription.planCode, addOnCode)
  257. } catch (err) {
  258. if (err instanceof recurly.errors.NotFoundError) {
  259. throw new NotFoundError({
  260. message: 'Add-on not found',
  261. info: { addOnCode },
  262. })
  263. }
  264. throw err
  265. }
  266. const changeRequest = subscription.getRequestForAddOnPurchase(addOnCode)
  267. const change =
  268. await RecurlyClient.promises.previewSubscriptionChange(changeRequest)
  269. return change
  270. }
  271. /**
  272. * Purchase an add-on for a user
  273. *
  274. * @param {string} userId
  275. * @param {string} addOnCode
  276. * @param {number} quantity
  277. */
  278. async function purchaseAddon(userId, addOnCode, quantity) {
  279. const subscription = await getSubscriptionForUser(userId)
  280. try {
  281. await RecurlyClient.promises.getAddOn(subscription.planCode, addOnCode)
  282. } catch (err) {
  283. if (err instanceof recurly.errors.NotFoundError) {
  284. throw new NotFoundError({
  285. message: 'Add-on not found',
  286. info: { addOnCode },
  287. })
  288. }
  289. throw err
  290. }
  291. const changeRequest = subscription.getRequestForAddOnPurchase(
  292. addOnCode,
  293. quantity
  294. )
  295. await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
  296. await syncSubscription({ uuid: subscription.id }, userId)
  297. }
  298. /**
  299. * Cancels and add-on for a user
  300. *
  301. * @param {string} userId
  302. * @param {string} addOnCode
  303. */
  304. async function removeAddon(userId, addOnCode) {
  305. const subscription = await getSubscriptionForUser(userId)
  306. const changeRequest = subscription.getRequestForAddOnRemoval(addOnCode)
  307. await RecurlyClient.promises.applySubscriptionChangeRequest(changeRequest)
  308. await syncSubscription({ uuid: subscription.id }, userId)
  309. }
  310. /**
  311. * Returns the Recurly UUID for the given user
  312. *
  313. * Throws a NotFoundError if the subscription can't be found
  314. *
  315. * @param {string} userId
  316. * @return {Promise<RecurlySubscription>}
  317. */
  318. async function getSubscriptionForUser(userId) {
  319. const subscription =
  320. await SubscriptionLocator.promises.getUsersSubscription(userId)
  321. const recurlyId = subscription?.recurlySubscription_id
  322. if (recurlyId == null) {
  323. throw new NotFoundError({
  324. message: 'Recurly subscription not found',
  325. info: { userId },
  326. })
  327. }
  328. try {
  329. const subscription = await RecurlyClient.promises.getSubscription(recurlyId)
  330. return subscription
  331. } catch (err) {
  332. if (err instanceof recurly.errors.NotFoundError) {
  333. throw new NotFoundError({
  334. message: 'Subscription not found',
  335. info: { userId, recurlyId },
  336. })
  337. } else {
  338. throw err
  339. }
  340. }
  341. }
  342. module.exports = {
  343. validateNoSubscriptionInRecurly: callbackify(validateNoSubscriptionInRecurly),
  344. createSubscription: callbackify(createSubscription),
  345. previewSubscriptionChange: callbackify(previewSubscriptionChange),
  346. updateSubscription: callbackify(updateSubscription),
  347. cancelPendingSubscriptionChange: callbackify(cancelPendingSubscriptionChange),
  348. cancelSubscription: callbackify(cancelSubscription),
  349. reactivateSubscription: callbackify(reactivateSubscription),
  350. syncSubscription: callbackify(syncSubscription),
  351. attemptPaypalInvoiceCollection: callbackify(attemptPaypalInvoiceCollection),
  352. extendTrial: callbackify(extendTrial),
  353. previewAddonPurchase: callbackify(previewAddonPurchase),
  354. purchaseAddon: callbackify(purchaseAddon),
  355. removeAddon: callbackify(removeAddon),
  356. promises: {
  357. validateNoSubscriptionInRecurly,
  358. createSubscription,
  359. previewSubscriptionChange,
  360. updateSubscription,
  361. cancelPendingSubscriptionChange,
  362. cancelSubscription,
  363. reactivateSubscription,
  364. syncSubscription,
  365. attemptPaypalInvoiceCollection,
  366. extendTrial,
  367. previewAddonPurchase,
  368. purchaseAddon,
  369. removeAddon,
  370. },
  371. }