RecurlyClient.js 12 KB

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