RecurlyClient.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. // @ts-check
  2. const recurly = require('recurly')
  3. const Settings = require('@overleaf/settings')
  4. const logger = require('@overleaf/logger')
  5. const OError = require('@overleaf/o-error')
  6. const { callbackify } = require('util')
  7. const UserGetter = require('../User/UserGetter')
  8. const {
  9. RecurlySubscription,
  10. RecurlySubscriptionAddOn,
  11. RecurlySubscriptionChange,
  12. PaypalPaymentMethod,
  13. CreditCardPaymentMethod,
  14. RecurlyAddOn,
  15. RecurlyPlan,
  16. RecurlyImmediateCharge,
  17. } = require('./RecurlyEntities')
  18. const { MissingBillingInfoError } = require('./Errors')
  19. /**
  20. * @import { RecurlySubscriptionChangeRequest } from './RecurlyEntities'
  21. * @import { PaymentMethod } from './types'
  22. */
  23. const recurlySettings = Settings.apis.recurly
  24. const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
  25. const client = new recurly.Client(recurlyApiKey)
  26. async function getAccountForUserId(userId) {
  27. try {
  28. return await client.getAccount(`code-${userId}`)
  29. } catch (err) {
  30. if (err instanceof recurly.errors.NotFoundError) {
  31. // An expected error, we don't need to handle it, just return nothing
  32. logger.debug({ userId }, 'no recurly account found for user')
  33. } else {
  34. throw err
  35. }
  36. }
  37. }
  38. async function createAccountForUserId(userId) {
  39. const user = await UserGetter.promises.getUser(userId, {
  40. _id: 1,
  41. first_name: 1,
  42. last_name: 1,
  43. email: 1,
  44. })
  45. const accountCreate = {
  46. code: user._id.toString(),
  47. email: user.email,
  48. firstName: user.first_name,
  49. lastName: user.last_name,
  50. }
  51. const account = await client.createAccount(accountCreate)
  52. logger.debug({ userId, account }, 'created recurly account')
  53. return account
  54. }
  55. /**
  56. * Get a subscription from Recurly
  57. *
  58. * @param {string} subscriptionId
  59. * @return {Promise<RecurlySubscription>}
  60. */
  61. async function getSubscription(subscriptionId) {
  62. const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
  63. return subscriptionFromApi(subscription)
  64. }
  65. /**
  66. * Get the subscription for a given user
  67. *
  68. * Returns null if the user doesn't have an account or a subscription. Throws an
  69. * error if the user has more than one subscription.
  70. *
  71. * @param {string} userId
  72. * @return {Promise<RecurlySubscription | null>}
  73. */
  74. async function getSubscriptionForUser(userId) {
  75. try {
  76. const subscriptions = client.listAccountSubscriptions(`code-${userId}`, {
  77. params: { state: 'active', limit: 2 },
  78. })
  79. let result = null
  80. // The async iterator returns a NotFoundError if the account doesn't exist.
  81. for await (const subscription of subscriptions.each()) {
  82. if (result != null) {
  83. throw new OError('User has more than one Recurly subscription', {
  84. userId,
  85. })
  86. }
  87. result = subscription
  88. }
  89. if (result == null) {
  90. return null
  91. }
  92. return subscriptionFromApi(result)
  93. } catch (err) {
  94. if (err instanceof recurly.errors.NotFoundError) {
  95. return null
  96. } else {
  97. throw err
  98. }
  99. }
  100. }
  101. /**
  102. * Request a susbcription change from Recurly
  103. *
  104. * @param {RecurlySubscriptionChangeRequest} changeRequest
  105. */
  106. async function applySubscriptionChangeRequest(changeRequest) {
  107. const body = subscriptionChangeRequestToApi(changeRequest)
  108. const change = await client.createSubscriptionChange(
  109. `uuid-${changeRequest.subscription.id}`,
  110. body
  111. )
  112. logger.debug(
  113. { subscriptionId: changeRequest.subscription.id, changeId: change.id },
  114. 'created subscription change'
  115. )
  116. }
  117. /**
  118. * Preview a subscription change
  119. *
  120. * @param {RecurlySubscriptionChangeRequest} changeRequest
  121. * @return {Promise<RecurlySubscriptionChange>}
  122. */
  123. async function previewSubscriptionChange(changeRequest) {
  124. const body = subscriptionChangeRequestToApi(changeRequest)
  125. const subscriptionChange = await client.previewSubscriptionChange(
  126. `uuid-${changeRequest.subscription.id}`,
  127. body
  128. )
  129. return subscriptionChangeFromApi(
  130. changeRequest.subscription,
  131. subscriptionChange
  132. )
  133. }
  134. async function removeSubscriptionChange(subscriptionId) {
  135. const removed = await client.removeSubscriptionChange(subscriptionId)
  136. logger.debug({ subscriptionId }, 'removed pending subscription change')
  137. return removed
  138. }
  139. async function removeSubscriptionChangeByUuid(subscriptionUuid) {
  140. return await removeSubscriptionChange('uuid-' + subscriptionUuid)
  141. }
  142. async function reactivateSubscriptionByUuid(subscriptionUuid) {
  143. return await client.reactivateSubscription('uuid-' + subscriptionUuid)
  144. }
  145. async function cancelSubscriptionByUuid(subscriptionUuid) {
  146. try {
  147. return await client.cancelSubscription('uuid-' + subscriptionUuid)
  148. } catch (err) {
  149. if (err instanceof recurly.errors.ValidationError) {
  150. if (
  151. err.message === 'Only active and future subscriptions can be canceled.'
  152. ) {
  153. logger.debug(
  154. { subscriptionUuid },
  155. 'subscription cancellation failed, subscription not active'
  156. )
  157. }
  158. } else {
  159. throw err
  160. }
  161. }
  162. }
  163. async function pauseSubscriptionByUuid(subscriptionUuid, pauseCycles) {
  164. return await client.pauseSubscription('uuid-' + subscriptionUuid, {
  165. remainingPauseCycles: pauseCycles,
  166. })
  167. }
  168. async function resumeSubscriptionByUuid(subscriptionUuid) {
  169. return await client.resumeSubscription('uuid-' + subscriptionUuid)
  170. }
  171. /**
  172. * Get the payment method for the given user
  173. *
  174. * @param {string} userId
  175. * @return {Promise<PaymentMethod>}
  176. */
  177. async function getPaymentMethod(userId) {
  178. let billingInfo
  179. try {
  180. billingInfo = await client.getBillingInfo(`code-${userId}`)
  181. } catch (error) {
  182. if (error instanceof recurly.errors.NotFoundError) {
  183. throw new MissingBillingInfoError('This account has no billing info', {
  184. userId,
  185. })
  186. }
  187. throw error
  188. }
  189. return paymentMethodFromApi(billingInfo)
  190. }
  191. /**
  192. * Get the configuration for a given add-on
  193. *
  194. * @param {string} planCode
  195. * @param {string} addOnCode
  196. * @return {Promise<RecurlyAddOn>}
  197. */
  198. async function getAddOn(planCode, addOnCode) {
  199. const addOn = await client.getPlanAddOn(
  200. `code-${planCode}`,
  201. `code-${addOnCode}`
  202. )
  203. return addOnFromApi(addOn)
  204. }
  205. /**
  206. * Get the configuration for a given plan
  207. *
  208. * @param {string} planCode
  209. * @return {Promise<RecurlyPlan>}
  210. */
  211. async function getPlan(planCode) {
  212. const plan = await client.getPlan(`code-${planCode}`)
  213. return planFromApi(plan)
  214. }
  215. function subscriptionIsCanceledOrExpired(subscription) {
  216. const state = subscription?.recurlyStatus?.state
  217. return state === 'canceled' || state === 'expired'
  218. }
  219. /**
  220. * Build a RecurlySubscription from Recurly API data
  221. *
  222. * @param {recurly.Subscription} apiSubscription
  223. * @return {RecurlySubscription}
  224. */
  225. function subscriptionFromApi(apiSubscription) {
  226. if (
  227. apiSubscription.uuid == null ||
  228. apiSubscription.plan == null ||
  229. apiSubscription.plan.code == null ||
  230. apiSubscription.plan.name == null ||
  231. apiSubscription.account == null ||
  232. apiSubscription.account.code == null ||
  233. apiSubscription.unitAmount == null ||
  234. apiSubscription.subtotal == null ||
  235. apiSubscription.total == null ||
  236. apiSubscription.currency == null ||
  237. apiSubscription.currentPeriodStartedAt == null ||
  238. apiSubscription.currentPeriodEndsAt == null ||
  239. apiSubscription.createdAt == null ||
  240. apiSubscription.collectionMethod == null
  241. ) {
  242. throw new OError('Invalid Recurly subscription', {
  243. subscription: apiSubscription,
  244. })
  245. }
  246. const subscription = new RecurlySubscription({
  247. id: apiSubscription.uuid,
  248. userId: apiSubscription.account.code,
  249. planCode: apiSubscription.plan.code,
  250. planName: apiSubscription.plan.name,
  251. planPrice: apiSubscription.unitAmount,
  252. addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
  253. subtotal: apiSubscription.subtotal,
  254. taxRate: apiSubscription.taxInfo?.rate ?? 0,
  255. taxAmount: apiSubscription.tax ?? 0,
  256. total: apiSubscription.total,
  257. currency: apiSubscription.currency,
  258. periodStart: apiSubscription.currentPeriodStartedAt,
  259. periodEnd: apiSubscription.currentPeriodEndsAt,
  260. createdAt: apiSubscription.createdAt,
  261. collectionMethod: apiSubscription.collectionMethod,
  262. })
  263. if (apiSubscription.pendingChange != null) {
  264. subscription.pendingChange = subscriptionChangeFromApi(
  265. subscription,
  266. apiSubscription.pendingChange
  267. )
  268. }
  269. return subscription
  270. }
  271. /**
  272. * Build a RecurlySubscriptionAddOn from Recurly API data
  273. *
  274. * @param {recurly.SubscriptionAddOn} addOn
  275. * @return {RecurlySubscriptionAddOn}
  276. */
  277. function subscriptionAddOnFromApi(addOn) {
  278. if (
  279. addOn.addOn == null ||
  280. addOn.addOn.code == null ||
  281. addOn.addOn.name == null ||
  282. addOn.unitAmount == null
  283. ) {
  284. throw new OError('Invalid Recurly add-on', { addOn })
  285. }
  286. return new RecurlySubscriptionAddOn({
  287. code: addOn.addOn.code,
  288. name: addOn.addOn.name,
  289. quantity: addOn.quantity ?? 1,
  290. unitPrice: addOn.unitAmount,
  291. })
  292. }
  293. /**
  294. * Build a RecurlySubscriptionChange from Recurly API data
  295. *
  296. * @param {RecurlySubscription} subscription - the current subscription
  297. * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
  298. * @return {RecurlySubscriptionChange}
  299. */
  300. function subscriptionChangeFromApi(subscription, subscriptionChange) {
  301. if (
  302. subscriptionChange.plan == null ||
  303. subscriptionChange.plan.code == null ||
  304. subscriptionChange.plan.name == null ||
  305. subscriptionChange.unitAmount == null
  306. ) {
  307. throw new OError('Invalid Recurly subscription change', {
  308. subscriptionChange,
  309. })
  310. }
  311. const nextAddOns = (subscriptionChange.addOns ?? []).map(
  312. subscriptionAddOnFromApi
  313. )
  314. return new RecurlySubscriptionChange({
  315. subscription,
  316. nextPlanCode: subscriptionChange.plan.code,
  317. nextPlanName: subscriptionChange.plan.name,
  318. nextPlanPrice: subscriptionChange.unitAmount,
  319. nextAddOns,
  320. immediateCharge: computeImmediateCharge(subscriptionChange),
  321. })
  322. }
  323. /**
  324. * Compute immediate charge based on invoice collection
  325. *
  326. * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
  327. * @return {RecurlyImmediateCharge}
  328. */
  329. function computeImmediateCharge(subscriptionChange) {
  330. const roundToTwoDecimal = (/** @type {number} */ num) =>
  331. Math.round(num * 100) / 100
  332. let subtotal =
  333. subscriptionChange.invoiceCollection?.chargeInvoice?.subtotal ?? 0
  334. let tax = subscriptionChange.invoiceCollection?.chargeInvoice?.tax ?? 0
  335. let total = subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0
  336. let discount =
  337. subscriptionChange.invoiceCollection?.chargeInvoice?.discount ?? 0
  338. for (const creditInvoice of subscriptionChange.invoiceCollection
  339. ?.creditInvoices ?? []) {
  340. // The credit invoice numbers are already negative
  341. subtotal = roundToTwoDecimal(subtotal + (creditInvoice.subtotal ?? 0))
  342. total = roundToTwoDecimal(total + (creditInvoice.total ?? 0))
  343. // Tax rate can be different in credit invoice if a user relocates
  344. tax = roundToTwoDecimal(tax + (creditInvoice.tax ?? 0))
  345. discount = roundToTwoDecimal(discount + (creditInvoice.discount ?? 0))
  346. }
  347. return new RecurlyImmediateCharge({
  348. subtotal,
  349. total,
  350. tax,
  351. discount,
  352. })
  353. }
  354. /**
  355. * Returns a payment method from Recurly API data
  356. *
  357. * @param {recurly.BillingInfo} billingInfo
  358. * @return {PaymentMethod}
  359. */
  360. function paymentMethodFromApi(billingInfo) {
  361. if (billingInfo.paymentMethod == null) {
  362. throw new OError('Invalid Recurly billing info', { billingInfo })
  363. }
  364. const paymentMethod = billingInfo.paymentMethod
  365. if (paymentMethod.billingAgreementId != null) {
  366. return new PaypalPaymentMethod()
  367. }
  368. if (paymentMethod.cardType == null || paymentMethod.lastFour == null) {
  369. throw new OError('Invalid Recurly billing info', { billingInfo })
  370. }
  371. return new CreditCardPaymentMethod({
  372. cardType: paymentMethod.cardType,
  373. lastFour: paymentMethod.lastFour,
  374. })
  375. }
  376. /**
  377. * Build a RecurlyAddOn from Recurly API data
  378. *
  379. * @param {recurly.AddOn} addOn
  380. * @return {RecurlyAddOn}
  381. */
  382. function addOnFromApi(addOn) {
  383. if (addOn.code == null || addOn.name == null) {
  384. throw new OError('Invalid Recurly add-on', { addOn })
  385. }
  386. return new RecurlyAddOn({
  387. code: addOn.code,
  388. name: addOn.name,
  389. })
  390. }
  391. /**
  392. * Build a RecurlyPlan from Recurly API data
  393. *
  394. * @param {recurly.Plan} plan
  395. * @return {RecurlyPlan}
  396. */
  397. function planFromApi(plan) {
  398. if (plan.code == null || plan.name == null) {
  399. throw new OError('Invalid Recurly add-on', { plan })
  400. }
  401. return new RecurlyPlan({
  402. code: plan.code,
  403. name: plan.name,
  404. })
  405. }
  406. /**
  407. * Build an API request from a RecurlySubscriptionChangeRequest
  408. *
  409. * @param {RecurlySubscriptionChangeRequest} changeRequest
  410. * @return {recurly.SubscriptionChangeCreate}
  411. */
  412. function subscriptionChangeRequestToApi(changeRequest) {
  413. /** @type {recurly.SubscriptionChangeCreate} */
  414. const requestBody = {
  415. timeframe: changeRequest.timeframe,
  416. }
  417. if (changeRequest.planCode != null) {
  418. requestBody.planCode = changeRequest.planCode
  419. }
  420. if (changeRequest.addOnUpdates != null) {
  421. requestBody.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
  422. /** @type {recurly.SubscriptionAddOnUpdate} */
  423. const update = { code: addOnUpdate.code }
  424. if (addOnUpdate.quantity != null) {
  425. update.quantity = addOnUpdate.quantity
  426. }
  427. if (addOnUpdate.unitPrice != null) {
  428. update.unitAmount = addOnUpdate.unitPrice
  429. }
  430. return update
  431. })
  432. }
  433. return requestBody
  434. }
  435. module.exports = {
  436. errors: recurly.errors,
  437. getAccountForUserId: callbackify(getAccountForUserId),
  438. createAccountForUserId: callbackify(createAccountForUserId),
  439. getSubscription: callbackify(getSubscription),
  440. getSubscriptionForUser: callbackify(getSubscriptionForUser),
  441. previewSubscriptionChange: callbackify(previewSubscriptionChange),
  442. applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
  443. removeSubscriptionChange: callbackify(removeSubscriptionChange),
  444. removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
  445. reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
  446. cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
  447. getPaymentMethod: callbackify(getPaymentMethod),
  448. getAddOn: callbackify(getAddOn),
  449. getPlan: callbackify(getPlan),
  450. subscriptionIsCanceledOrExpired,
  451. pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
  452. resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
  453. promises: {
  454. getSubscription,
  455. getSubscriptionForUser,
  456. getAccountForUserId,
  457. createAccountForUserId,
  458. previewSubscriptionChange,
  459. applySubscriptionChangeRequest,
  460. removeSubscriptionChange,
  461. removeSubscriptionChangeByUuid,
  462. reactivateSubscriptionByUuid,
  463. cancelSubscriptionByUuid,
  464. pauseSubscriptionByUuid,
  465. resumeSubscriptionByUuid,
  466. getPaymentMethod,
  467. getAddOn,
  468. getPlan,
  469. },
  470. }