RecurlyClient.js 24 KB

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