RecurlyClient.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803
  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. PaymentProviderSubscription,
  10. PaymentProviderSubscriptionAddOn,
  11. PaymentProviderSubscriptionChange,
  12. PaypalPaymentMethod,
  13. CreditCardPaymentMethod,
  14. PaymentProviderAddOn,
  15. PaymentProviderPlan,
  16. PaymentProviderCoupon,
  17. PaymentProviderAccount,
  18. PaymentProviderImmediateCharge,
  19. } = require('./PaymentProviderEntities')
  20. const {
  21. MissingBillingInfoError,
  22. SubtotalLimitExceededError,
  23. } = require('./Errors')
  24. const RecurlyMetrics = require('./RecurlyMetrics')
  25. /**
  26. * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities'
  27. * @import { PaymentProviderSubscriptionUpdateRequest } from './PaymentProviderEntities'
  28. * @import { PaymentMethod } from './types'
  29. * @import { CurrencyCode } from '../../../../types/subscription/currency'
  30. */
  31. class RecurlyClientWithErrorHandling extends recurly.Client {
  32. /**
  33. * @param {import('recurly/lib/recurly/Http').Response} response
  34. * @return {Error | null}
  35. * @private
  36. */
  37. _errorFromResponse(response) {
  38. RecurlyMetrics.recordMetrics(
  39. response.status,
  40. response.rateLimit,
  41. response.rateLimitRemaining,
  42. response.rateLimitReset.getTime()
  43. )
  44. // @ts-ignore
  45. return super._errorFromResponse(response)
  46. }
  47. }
  48. const recurlySettings = Settings.apis.recurly
  49. const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined
  50. const client = new RecurlyClientWithErrorHandling(recurlyApiKey)
  51. /**
  52. * Get account for a given user
  53. *
  54. * @param {string} userId
  55. * @return {Promise<PaymentProviderAccount | null>}
  56. */
  57. async function getAccountForUserId(userId) {
  58. try {
  59. const account = await client.getAccount(`code-${userId}`)
  60. return accountFromApi(account)
  61. } catch (err) {
  62. if (err instanceof recurly.errors.NotFoundError) {
  63. // An expected error, we don't need to handle it, just return nothing
  64. logger.debug({ userId }, 'no recurly account found for user')
  65. return null
  66. } else {
  67. throw err
  68. }
  69. }
  70. }
  71. async function createAccountForUserId(userId) {
  72. const user = await UserGetter.promises.getUser(userId, {
  73. _id: 1,
  74. first_name: 1,
  75. last_name: 1,
  76. email: 1,
  77. })
  78. const accountCreate = {
  79. code: user._id.toString(),
  80. email: user.email,
  81. firstName: user.first_name,
  82. lastName: user.last_name,
  83. }
  84. const account = await client.createAccount(accountCreate)
  85. logger.debug({ userId, account }, 'created recurly account')
  86. return account
  87. }
  88. /**
  89. * Get active coupons for a given user
  90. *
  91. * @param {string} userId
  92. * @return {Promise<PaymentProviderCoupon[]>}
  93. */
  94. async function getActiveCouponsForUserId(userId) {
  95. try {
  96. const redemptions = await client.listActiveCouponRedemptions(
  97. `code-${userId}`
  98. )
  99. const coupons = []
  100. for await (const redemption of redemptions.each()) {
  101. coupons.push(couponFromApi(redemption))
  102. }
  103. return coupons
  104. } catch (err) {
  105. // An expected error if no coupons have been redeemed
  106. if (err instanceof recurly.errors.NotFoundError) {
  107. return []
  108. } else {
  109. throw err
  110. }
  111. }
  112. }
  113. /**
  114. * Get hosted customer management link
  115. *
  116. * @param {string} userId
  117. * @param {string} pageType
  118. * @return {Promise<string|null>}
  119. */
  120. async function getCustomerManagementLink(userId, pageType) {
  121. try {
  122. const account = await client.getAccount(`code-${userId}`)
  123. const recurlySubdomain = Settings.apis.recurly.subdomain
  124. const hostedLoginToken = account.hostedLoginToken
  125. if (!hostedLoginToken) {
  126. throw new OError('recurly account does not have hosted login token')
  127. }
  128. let path = ''
  129. if (pageType === 'billing-details') {
  130. path = 'billing_info/edit?ht='
  131. }
  132. return [
  133. 'https://',
  134. recurlySubdomain,
  135. '.recurly.com/account/',
  136. path,
  137. hostedLoginToken,
  138. ].join('')
  139. } catch (err) {
  140. if (err instanceof recurly.errors.NotFoundError) {
  141. // An expected error, we don't need to handle it, just return nothing
  142. logger.debug({ userId }, 'no recurly account found for user')
  143. return null
  144. } else {
  145. throw err
  146. }
  147. }
  148. }
  149. /**
  150. * Get a subscription from Recurly
  151. *
  152. * @param {string} subscriptionId
  153. * @return {Promise<PaymentProviderSubscription>}
  154. */
  155. async function getSubscription(subscriptionId) {
  156. const subscription = await client.getSubscription(`uuid-${subscriptionId}`)
  157. return subscriptionFromApi(subscription)
  158. }
  159. /**
  160. * Get the subscription for a given user
  161. *
  162. * Returns null if the user doesn't have an account or a subscription. Throws an
  163. * error if the user has more than one subscription.
  164. *
  165. * @param {string} userId
  166. * @return {Promise<PaymentProviderSubscription | null>}
  167. */
  168. async function getSubscriptionForUser(userId) {
  169. try {
  170. const subscriptions = client.listAccountSubscriptions(`code-${userId}`, {
  171. params: { state: 'active', limit: 2 },
  172. })
  173. let result = null
  174. // The async iterator returns a NotFoundError if the account doesn't exist.
  175. for await (const subscription of subscriptions.each()) {
  176. if (result != null) {
  177. throw new OError('User has more than one Recurly subscription', {
  178. userId,
  179. })
  180. }
  181. result = subscription
  182. }
  183. if (result == null) {
  184. return null
  185. }
  186. return subscriptionFromApi(result)
  187. } catch (err) {
  188. if (err instanceof recurly.errors.NotFoundError) {
  189. return null
  190. } else {
  191. throw err
  192. }
  193. }
  194. }
  195. /**
  196. * Request a subscription update from Recurly
  197. *
  198. * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
  199. */
  200. async function updateSubscriptionDetails(updateRequest) {
  201. const body = subscriptionUpdateRequestToApi(updateRequest)
  202. const updatedSubscription = await client.updateSubscription(
  203. `uuid-${updateRequest.subscription.id}`,
  204. body
  205. )
  206. logger.debug(
  207. {
  208. subscriptionId: updateRequest.subscription.id,
  209. updateId: updatedSubscription.id,
  210. },
  211. 'updated subscription'
  212. )
  213. }
  214. /**
  215. * Request a subscription change from Recurly
  216. *
  217. * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
  218. */
  219. async function applySubscriptionChangeRequest(changeRequest) {
  220. const body = subscriptionChangeRequestToApi(changeRequest)
  221. try {
  222. const change = await client.createSubscriptionChange(
  223. `uuid-${changeRequest.subscription.id}`,
  224. body
  225. )
  226. logger.debug(
  227. { subscriptionId: changeRequest.subscription.id, changeId: change.id },
  228. 'created subscription change'
  229. )
  230. } catch (err) {
  231. if (err instanceof recurly.errors.ValidationError) {
  232. /**
  233. * @type {{params?: { param?: string }[] | null}}
  234. */
  235. const validationError = err
  236. if (
  237. validationError.params?.some(
  238. p => p.param === 'subtotal_amount_in_cents'
  239. )
  240. ) {
  241. throw new SubtotalLimitExceededError(
  242. 'Subtotal amount in cents exceeded error',
  243. {
  244. subscriptionId: changeRequest.subscription.id,
  245. }
  246. )
  247. }
  248. }
  249. throw err
  250. }
  251. }
  252. /**
  253. * Preview a subscription change
  254. *
  255. * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
  256. * @return {Promise<PaymentProviderSubscriptionChange>}
  257. */
  258. async function previewSubscriptionChange(changeRequest) {
  259. const body = subscriptionChangeRequestToApi(changeRequest)
  260. try {
  261. const subscriptionChange = await client.previewSubscriptionChange(
  262. `uuid-${changeRequest.subscription.id}`,
  263. body
  264. )
  265. return subscriptionChangeFromApi(
  266. changeRequest.subscription,
  267. subscriptionChange
  268. )
  269. } catch (err) {
  270. if (err instanceof recurly.errors.ValidationError) {
  271. /**
  272. * @type {{params?: { param?: string }[] | null}}
  273. */
  274. const validationError = err
  275. if (
  276. validationError.params?.some(
  277. p => p.param === 'subtotal_amount_in_cents'
  278. )
  279. ) {
  280. throw new SubtotalLimitExceededError(
  281. 'Subtotal amount in cents exceeded error',
  282. {
  283. subscriptionId: changeRequest.subscription.id,
  284. }
  285. )
  286. }
  287. }
  288. throw err
  289. }
  290. }
  291. async function removeSubscriptionChange(subscriptionId) {
  292. const removed = await client.removeSubscriptionChange(subscriptionId)
  293. logger.debug({ subscriptionId }, 'removed pending subscription change')
  294. return removed
  295. }
  296. async function removeSubscriptionChangeByUuid(subscriptionUuid) {
  297. return await removeSubscriptionChange('uuid-' + subscriptionUuid)
  298. }
  299. async function reactivateSubscriptionByUuid(subscriptionUuid) {
  300. return await client.reactivateSubscription('uuid-' + subscriptionUuid)
  301. }
  302. async function cancelSubscriptionByUuid(subscriptionUuid) {
  303. try {
  304. return await client.cancelSubscription('uuid-' + subscriptionUuid)
  305. } catch (err) {
  306. if (err instanceof recurly.errors.ValidationError) {
  307. if (
  308. err.message === 'Only active and future subscriptions can be canceled.'
  309. ) {
  310. logger.debug(
  311. { subscriptionUuid },
  312. 'subscription cancellation failed, subscription not active'
  313. )
  314. }
  315. } else {
  316. throw err
  317. }
  318. }
  319. }
  320. async function pauseSubscriptionByUuid(subscriptionUuid, pauseCycles) {
  321. return await client.pauseSubscription('uuid-' + subscriptionUuid, {
  322. remainingPauseCycles: pauseCycles,
  323. })
  324. }
  325. async function resumeSubscriptionByUuid(subscriptionUuid) {
  326. return await client.resumeSubscription('uuid-' + subscriptionUuid)
  327. }
  328. /**
  329. * Get the payment method for the given user
  330. *
  331. * @param {string} userId
  332. * @return {Promise<PaymentMethod>}
  333. */
  334. async function getPaymentMethod(userId) {
  335. let billingInfo
  336. try {
  337. billingInfo = await client.getBillingInfo(`code-${userId}`)
  338. } catch (error) {
  339. if (error instanceof recurly.errors.NotFoundError) {
  340. throw new MissingBillingInfoError('This account has no billing info', {
  341. userId,
  342. })
  343. }
  344. throw error
  345. }
  346. return paymentMethodFromApi(billingInfo)
  347. }
  348. /**
  349. * Get the configuration for a given add-on
  350. *
  351. * @param {string} planCode
  352. * @param {string} addOnCode
  353. * @return {Promise<PaymentProviderAddOn>}
  354. */
  355. async function getAddOn(planCode, addOnCode) {
  356. const addOn = await client.getPlanAddOn(
  357. `code-${planCode}`,
  358. `code-${addOnCode}`
  359. )
  360. return addOnFromApi(addOn)
  361. }
  362. /**
  363. * Get the configuration for a given plan
  364. *
  365. * @param {string} planCode
  366. * @return {Promise<PaymentProviderPlan>}
  367. */
  368. async function getPlan(planCode) {
  369. const plan = await client.getPlan(`code-${planCode}`)
  370. return planFromApi(plan)
  371. }
  372. function subscriptionIsCanceledOrExpired(subscription) {
  373. const state = subscription?.recurlyStatus?.state
  374. return state === 'canceled' || state === 'expired'
  375. }
  376. /**
  377. * Build a PaymentProviderAccount from Recurly API data
  378. *
  379. * @param {recurly.Account} apiAccount
  380. * @return {PaymentProviderAccount}
  381. */
  382. function accountFromApi(apiAccount) {
  383. if (apiAccount.code == null || apiAccount.email == null) {
  384. throw new OError('Invalid Recurly account', {
  385. account: apiAccount,
  386. })
  387. }
  388. return new PaymentProviderAccount({
  389. code: apiAccount.code,
  390. email: apiAccount.email,
  391. hasPastDueInvoice: apiAccount.hasPastDueInvoice ?? false,
  392. })
  393. }
  394. /**
  395. * Build a PaymentProviderCoupon from Recurly API data
  396. *
  397. * @param {recurly.CouponRedemption} apiRedemption
  398. * @return {PaymentProviderCoupon}
  399. */
  400. function couponFromApi(apiRedemption) {
  401. if (apiRedemption.coupon == null || apiRedemption.coupon.code == null) {
  402. throw new OError('Invalid Recurly coupon', {
  403. coupon: apiRedemption,
  404. })
  405. }
  406. return new PaymentProviderCoupon({
  407. code: apiRedemption.coupon.code,
  408. name: apiRedemption.coupon.name ?? '',
  409. description: apiRedemption.coupon.hostedPageDescription ?? '',
  410. })
  411. }
  412. /**
  413. * Build a PaymentProviderSubscription from Recurly API data
  414. *
  415. * @param {recurly.Subscription} apiSubscription
  416. * @return {PaymentProviderSubscription}
  417. */
  418. function subscriptionFromApi(apiSubscription) {
  419. if (
  420. apiSubscription.uuid == null ||
  421. apiSubscription.plan == null ||
  422. apiSubscription.plan.code == null ||
  423. apiSubscription.plan.name == null ||
  424. apiSubscription.account == null ||
  425. apiSubscription.account.code == null ||
  426. apiSubscription.unitAmount == null ||
  427. apiSubscription.subtotal == null ||
  428. apiSubscription.total == null ||
  429. apiSubscription.currency == null ||
  430. apiSubscription.currentPeriodStartedAt == null ||
  431. apiSubscription.currentPeriodEndsAt == null ||
  432. apiSubscription.collectionMethod == null ||
  433. apiSubscription.netTerms == null ||
  434. // The values below could be null initially if the subscription has never updated
  435. !('poNumber' in apiSubscription) ||
  436. !('termsAndConditions' in apiSubscription)
  437. ) {
  438. throw new OError('Invalid Recurly subscription', {
  439. subscription: apiSubscription,
  440. })
  441. }
  442. const subscription = new PaymentProviderSubscription({
  443. id: apiSubscription.uuid,
  444. userId: apiSubscription.account.code,
  445. planCode: apiSubscription.plan.code,
  446. planName: apiSubscription.plan.name,
  447. planPrice: apiSubscription.unitAmount,
  448. addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
  449. subtotal: apiSubscription.subtotal,
  450. taxRate: apiSubscription.taxInfo?.rate ?? 0,
  451. taxAmount: apiSubscription.tax ?? 0,
  452. total: apiSubscription.total,
  453. currency: /** @type {CurrencyCode} */ (apiSubscription.currency),
  454. periodStart: apiSubscription.currentPeriodStartedAt,
  455. periodEnd: apiSubscription.currentPeriodEndsAt,
  456. collectionMethod: apiSubscription.collectionMethod,
  457. netTerms: apiSubscription.netTerms ?? 0,
  458. poNumber: apiSubscription.poNumber ?? '',
  459. termsAndConditions: apiSubscription.termsAndConditions ?? '',
  460. service: 'recurly',
  461. state: apiSubscription.state ?? 'active',
  462. trialPeriodStart: apiSubscription.trialStartedAt,
  463. trialPeriodEnd: apiSubscription.trialEndsAt,
  464. pausePeriodStart: apiSubscription.pausedAt,
  465. remainingPauseCycles: apiSubscription.remainingPauseCycles,
  466. })
  467. if (apiSubscription.pendingChange != null) {
  468. subscription.pendingChange = subscriptionChangeFromApi(
  469. subscription,
  470. apiSubscription.pendingChange
  471. )
  472. }
  473. return subscription
  474. }
  475. /**
  476. * Build a PaymentProviderSubscriptionAddOn from Recurly API data
  477. *
  478. * @param {recurly.SubscriptionAddOn} addOn
  479. * @return {PaymentProviderSubscriptionAddOn}
  480. */
  481. function subscriptionAddOnFromApi(addOn) {
  482. if (
  483. addOn.addOn == null ||
  484. addOn.addOn.code == null ||
  485. addOn.addOn.name == null ||
  486. addOn.unitAmount == null
  487. ) {
  488. throw new OError('Invalid Recurly add-on', { addOn })
  489. }
  490. return new PaymentProviderSubscriptionAddOn({
  491. code: addOn.addOn.code,
  492. name: addOn.addOn.name,
  493. quantity: addOn.quantity ?? 1,
  494. unitPrice: addOn.unitAmount,
  495. })
  496. }
  497. /**
  498. * Build a PaymentProviderSubscriptionChange from Recurly API data
  499. *
  500. * @param {PaymentProviderSubscription} subscription - the current subscription
  501. * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
  502. * @return {PaymentProviderSubscriptionChange}
  503. */
  504. function subscriptionChangeFromApi(subscription, subscriptionChange) {
  505. if (
  506. subscriptionChange.plan == null ||
  507. subscriptionChange.plan.code == null ||
  508. subscriptionChange.plan.name == null ||
  509. subscriptionChange.unitAmount == null
  510. ) {
  511. throw new OError('Invalid Recurly subscription change', {
  512. subscriptionChange,
  513. })
  514. }
  515. const nextAddOns = (subscriptionChange.addOns ?? []).map(
  516. subscriptionAddOnFromApi
  517. )
  518. return new PaymentProviderSubscriptionChange({
  519. subscription,
  520. nextPlanCode: subscriptionChange.plan.code,
  521. nextPlanName: subscriptionChange.plan.name,
  522. nextPlanPrice: subscriptionChange.unitAmount,
  523. nextAddOns,
  524. immediateCharge: computeImmediateCharge(subscriptionChange),
  525. })
  526. }
  527. /**
  528. * Compute immediate charge based on invoice collection
  529. *
  530. * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
  531. * @return {PaymentProviderImmediateCharge}
  532. */
  533. function computeImmediateCharge(subscriptionChange) {
  534. const roundToTwoDecimal = (/** @type {number} */ num) =>
  535. Math.round(num * 100) / 100
  536. let subtotal =
  537. subscriptionChange.invoiceCollection?.chargeInvoice?.subtotal ?? 0
  538. let tax = subscriptionChange.invoiceCollection?.chargeInvoice?.tax ?? 0
  539. let total = subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0
  540. let discount =
  541. subscriptionChange.invoiceCollection?.chargeInvoice?.discount ?? 0
  542. for (const creditInvoice of subscriptionChange.invoiceCollection
  543. ?.creditInvoices ?? []) {
  544. // The credit invoice numbers are already negative
  545. subtotal = roundToTwoDecimal(subtotal + (creditInvoice.subtotal ?? 0))
  546. total = roundToTwoDecimal(total + (creditInvoice.total ?? 0))
  547. // Tax rate can be different in credit invoice if a user relocates
  548. tax = roundToTwoDecimal(tax + (creditInvoice.tax ?? 0))
  549. discount = roundToTwoDecimal(discount + (creditInvoice.discount ?? 0))
  550. }
  551. return new PaymentProviderImmediateCharge({
  552. subtotal,
  553. total,
  554. tax,
  555. discount,
  556. })
  557. }
  558. /**
  559. * Returns a payment method from Recurly API data
  560. *
  561. * @param {recurly.BillingInfo} billingInfo
  562. * @return {PaymentMethod}
  563. */
  564. function paymentMethodFromApi(billingInfo) {
  565. if (billingInfo.paymentMethod == null) {
  566. throw new OError('Invalid Recurly billing info', { billingInfo })
  567. }
  568. const paymentMethod = billingInfo.paymentMethod
  569. if (paymentMethod.billingAgreementId != null) {
  570. return new PaypalPaymentMethod()
  571. }
  572. if (paymentMethod.cardType == null || paymentMethod.lastFour == null) {
  573. throw new OError('Invalid Recurly billing info', { billingInfo })
  574. }
  575. return new CreditCardPaymentMethod({
  576. cardType: paymentMethod.cardType,
  577. lastFour: paymentMethod.lastFour,
  578. })
  579. }
  580. /**
  581. * Build a PaymentProviderAddOn from Recurly API data
  582. *
  583. * @param {recurly.AddOn} addOn
  584. * @return {PaymentProviderAddOn}
  585. */
  586. function addOnFromApi(addOn) {
  587. if (addOn.code == null || addOn.name == null) {
  588. throw new OError('Invalid Recurly add-on', { addOn })
  589. }
  590. return new PaymentProviderAddOn({
  591. code: addOn.code,
  592. name: addOn.name,
  593. })
  594. }
  595. /**
  596. * Build a PaymentProviderPlan from Recurly API data
  597. *
  598. * @param {recurly.Plan} plan
  599. * @return {PaymentProviderPlan}
  600. */
  601. function planFromApi(plan) {
  602. if (plan.code == null || plan.name == null) {
  603. throw new OError('Invalid Recurly add-on', { plan })
  604. }
  605. return new PaymentProviderPlan({
  606. code: plan.code,
  607. name: plan.name,
  608. })
  609. }
  610. /**
  611. * Build an API request from a PaymentProviderSubscriptionChangeRequest
  612. *
  613. * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
  614. * @return {recurly.SubscriptionChangeCreate}
  615. */
  616. function subscriptionChangeRequestToApi(changeRequest) {
  617. /** @type {recurly.SubscriptionChangeCreate} */
  618. const requestBody = {
  619. timeframe: changeRequest.timeframe,
  620. }
  621. if (changeRequest.planCode != null) {
  622. requestBody.planCode = changeRequest.planCode
  623. }
  624. if (changeRequest.addOnUpdates != null) {
  625. requestBody.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
  626. /** @type {recurly.SubscriptionAddOnUpdate} */
  627. const update = { code: addOnUpdate.code }
  628. if (addOnUpdate.quantity != null) {
  629. update.quantity = addOnUpdate.quantity
  630. }
  631. if (addOnUpdate.unitPrice != null) {
  632. update.unitAmount = addOnUpdate.unitPrice
  633. }
  634. return update
  635. })
  636. }
  637. return requestBody
  638. }
  639. /**
  640. * Build an API request from a PaymentProviderSubscriptionUpdateRequest
  641. *
  642. * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
  643. */
  644. function subscriptionUpdateRequestToApi(updateRequest) {
  645. const requestBody = {}
  646. if (updateRequest.poNumber) {
  647. requestBody.poNumber = updateRequest.poNumber
  648. }
  649. if (updateRequest.termsAndConditions) {
  650. requestBody.termsAndConditions = updateRequest.termsAndConditions
  651. }
  652. return requestBody
  653. }
  654. /**
  655. * Retrieves a list of failed invoices for a given Recurly subscription ID.
  656. *
  657. * @async
  658. * @function
  659. * @param {string} subscriptionId - The ID of the Recurly subscription to fetch failed invoices for.
  660. * @returns {Promise<Array<recurly.Invoice>>} A promise that resolves to an array of failed invoice objects.
  661. */
  662. async function getPastDueInvoices(subscriptionId) {
  663. const failed = []
  664. const invoices = client.listSubscriptionInvoices(`uuid-${subscriptionId}`, {
  665. params: { state: 'past_due' },
  666. })
  667. for await (const invoice of invoices.each()) {
  668. failed.push(invoice)
  669. }
  670. return failed
  671. }
  672. /**
  673. * Marks an invoice as failed using the Recurly client.
  674. *
  675. * @async
  676. * @function failInvoice
  677. * @param {string} invoiceId - The ID of the invoice to be marked as failed.
  678. * @returns {Promise<void>} Resolves when the invoice has been successfully marked as failed.
  679. */
  680. async function failInvoice(invoiceId) {
  681. await client.markInvoiceFailed(invoiceId)
  682. }
  683. async function terminateSubscriptionByUuid(subscriptionUuid) {
  684. const subscription = await client.terminateSubscription(
  685. 'uuid-' + subscriptionUuid,
  686. {
  687. body: {
  688. refund: 'none',
  689. },
  690. }
  691. )
  692. logger.debug({ subscriptionUuid }, 'subscription terminated')
  693. return subscription
  694. }
  695. module.exports = {
  696. errors: recurly.errors,
  697. getAccountForUserId: callbackify(getAccountForUserId),
  698. createAccountForUserId: callbackify(createAccountForUserId),
  699. getActiveCouponsForUserId: callbackify(getActiveCouponsForUserId),
  700. getSubscription: callbackify(getSubscription),
  701. getSubscriptionForUser: callbackify(getSubscriptionForUser),
  702. previewSubscriptionChange: callbackify(previewSubscriptionChange),
  703. updateSubscriptionDetails: callbackify(updateSubscriptionDetails),
  704. applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
  705. removeSubscriptionChange: callbackify(removeSubscriptionChange),
  706. removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
  707. reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
  708. cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
  709. getPaymentMethod: callbackify(getPaymentMethod),
  710. getAddOn: callbackify(getAddOn),
  711. getPlan: callbackify(getPlan),
  712. subscriptionIsCanceledOrExpired,
  713. pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
  714. resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
  715. getPastDueInvoices: callbackify(getPastDueInvoices),
  716. failInvoice: callbackify(failInvoice),
  717. terminateSubscriptionByUuid: callbackify(terminateSubscriptionByUuid),
  718. promises: {
  719. getSubscription,
  720. getSubscriptionForUser,
  721. getAccountForUserId,
  722. createAccountForUserId,
  723. getActiveCouponsForUserId,
  724. getCustomerManagementLink,
  725. previewSubscriptionChange,
  726. updateSubscriptionDetails,
  727. applySubscriptionChangeRequest,
  728. removeSubscriptionChange,
  729. removeSubscriptionChangeByUuid,
  730. reactivateSubscriptionByUuid,
  731. cancelSubscriptionByUuid,
  732. pauseSubscriptionByUuid,
  733. resumeSubscriptionByUuid,
  734. getPaymentMethod,
  735. getAddOn,
  736. getPlan,
  737. getPastDueInvoices,
  738. failInvoice,
  739. terminateSubscriptionByUuid,
  740. },
  741. }