RecurlyClient.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  1. // @ts-check
  2. import recurly from 'recurly'
  3. import Settings from '@overleaf/settings'
  4. import logger from '@overleaf/logger'
  5. import OError from '@overleaf/o-error'
  6. import { callbackify } from 'node:util'
  7. import UserGetter from '../User/UserGetter.mjs'
  8. import {
  9. PaymentProviderSubscription,
  10. PaymentProviderSubscriptionAddOn,
  11. PaymentProviderSubscriptionChange,
  12. PaypalPaymentMethod,
  13. CreditCardPaymentMethod,
  14. PaymentProviderAddOn,
  15. PaymentProviderPlan,
  16. PaymentProviderCoupon,
  17. PaymentProviderAccount,
  18. PaymentProviderImmediateCharge,
  19. } from './PaymentProviderEntities.mjs'
  20. import {
  21. MissingBillingInfoError,
  22. SubtotalLimitExceededError,
  23. } from './Errors.js'
  24. import RecurlyMetrics from './RecurlyMetrics.mjs'
  25. import { isStandaloneAiAddOnPlanCode, AI_ADD_ON_CODE } from './AiHelper.js'
  26. /**
  27. * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities.mjs'
  28. * @import { PaymentProviderSubscriptionUpdateRequest } from './PaymentProviderEntities.mjs'
  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. throw err
  309. }
  310. const errorMessage = err.message || ''
  311. if (
  312. errorMessage === 'Only active and future subscriptions can be canceled.'
  313. ) {
  314. logger.debug(
  315. { subscriptionUuid },
  316. 'subscription cancellation failed, subscription not active'
  317. )
  318. } else if (
  319. errorMessage.includes(
  320. 'Cannot cancel a paused subscription in the last cycle of the term'
  321. )
  322. ) {
  323. logger.debug(
  324. { subscriptionUuid },
  325. 'Terminating subscription in last cycle of paused term'
  326. )
  327. return await terminateSubscriptionByUuid(subscriptionUuid)
  328. }
  329. throw err
  330. }
  331. }
  332. async function pauseSubscriptionByUuid(subscriptionUuid, pauseCycles) {
  333. return await client.pauseSubscription('uuid-' + subscriptionUuid, {
  334. remainingPauseCycles: pauseCycles,
  335. })
  336. }
  337. async function resumeSubscriptionByUuid(subscriptionUuid) {
  338. return await client.resumeSubscription('uuid-' + subscriptionUuid)
  339. }
  340. /**
  341. * Get the payment method for the given user
  342. *
  343. * @param {string} userId
  344. * @return {Promise<PaymentMethod>}
  345. */
  346. async function getPaymentMethod(userId) {
  347. let billingInfo
  348. try {
  349. billingInfo = await client.getBillingInfo(`code-${userId}`)
  350. } catch (error) {
  351. if (error instanceof recurly.errors.NotFoundError) {
  352. throw new MissingBillingInfoError('This account has no billing info', {
  353. userId,
  354. })
  355. }
  356. throw error
  357. }
  358. return paymentMethodFromApi(billingInfo)
  359. }
  360. /**
  361. * Get the configuration for a given add-on
  362. *
  363. * @param {string} planCode
  364. * @param {string} addOnCode
  365. * @return {Promise<PaymentProviderAddOn>}
  366. */
  367. async function getAddOn(planCode, addOnCode) {
  368. const addOn = await client.getPlanAddOn(
  369. `code-${planCode}`,
  370. `code-${addOnCode}`
  371. )
  372. return addOnFromApi(addOn)
  373. }
  374. /**
  375. * Get the configuration for a given plan
  376. *
  377. * @param {string} planCode
  378. * @return {Promise<PaymentProviderPlan>}
  379. */
  380. async function getPlan(planCode) {
  381. const plan = await client.getPlan(`code-${planCode}`)
  382. return planFromApi(plan)
  383. }
  384. function subscriptionIsCanceledOrExpired(subscription) {
  385. const state = subscription?.recurlyStatus?.state
  386. return state === 'canceled' || state === 'expired'
  387. }
  388. /**
  389. * Build a PaymentProviderAccount from Recurly API data
  390. *
  391. * @param {recurly.Account} apiAccount
  392. * @return {PaymentProviderAccount}
  393. */
  394. function accountFromApi(apiAccount) {
  395. if (apiAccount.code == null || apiAccount.email == null) {
  396. throw new OError('Invalid Recurly account', {
  397. account: apiAccount,
  398. })
  399. }
  400. return new PaymentProviderAccount({
  401. code: apiAccount.code,
  402. email: apiAccount.email,
  403. hasPastDueInvoice: apiAccount.hasPastDueInvoice ?? false,
  404. })
  405. }
  406. /**
  407. * Build a PaymentProviderCoupon from Recurly API data
  408. *
  409. * @param {recurly.CouponRedemption} apiRedemption
  410. * @return {PaymentProviderCoupon}
  411. */
  412. function couponFromApi(apiRedemption) {
  413. if (apiRedemption.coupon == null || apiRedemption.coupon.code == null) {
  414. throw new OError('Invalid Recurly coupon', {
  415. coupon: apiRedemption,
  416. })
  417. }
  418. return new PaymentProviderCoupon({
  419. code: apiRedemption.coupon.code,
  420. name: apiRedemption.coupon.name ?? '',
  421. description: apiRedemption.coupon.hostedPageDescription ?? '',
  422. })
  423. }
  424. /**
  425. * Build a PaymentProviderSubscription from Recurly API data
  426. *
  427. * @param {recurly.Subscription} apiSubscription
  428. * @return {PaymentProviderSubscription}
  429. */
  430. function subscriptionFromApi(apiSubscription) {
  431. if (
  432. apiSubscription.uuid == null ||
  433. apiSubscription.plan == null ||
  434. apiSubscription.plan.code == null ||
  435. apiSubscription.plan.name == null ||
  436. apiSubscription.account == null ||
  437. apiSubscription.account.code == null ||
  438. apiSubscription.unitAmount == null ||
  439. apiSubscription.subtotal == null ||
  440. apiSubscription.total == null ||
  441. apiSubscription.currency == null ||
  442. apiSubscription.currentPeriodStartedAt == null ||
  443. apiSubscription.currentPeriodEndsAt == null ||
  444. apiSubscription.collectionMethod == null ||
  445. apiSubscription.netTerms == null ||
  446. // The values below could be null initially if the subscription has never updated
  447. !('poNumber' in apiSubscription) ||
  448. !('termsAndConditions' in apiSubscription)
  449. ) {
  450. throw new OError('Invalid Recurly subscription', {
  451. subscription: apiSubscription,
  452. })
  453. }
  454. const subscription = new PaymentProviderSubscription({
  455. id: apiSubscription.uuid,
  456. userId: apiSubscription.account.code,
  457. planCode: apiSubscription.plan.code,
  458. planName: apiSubscription.plan.name,
  459. planPrice: apiSubscription.unitAmount,
  460. addOns: (apiSubscription.addOns ?? []).map(subscriptionAddOnFromApi),
  461. subtotal: apiSubscription.subtotal,
  462. taxRate: apiSubscription.taxInfo?.rate ?? 0,
  463. taxAmount: apiSubscription.tax ?? 0,
  464. total: apiSubscription.total,
  465. currency: /** @type {CurrencyCode} */ (apiSubscription.currency),
  466. periodStart: apiSubscription.currentPeriodStartedAt,
  467. periodEnd: apiSubscription.currentPeriodEndsAt,
  468. collectionMethod: apiSubscription.collectionMethod,
  469. netTerms: apiSubscription.netTerms ?? 0,
  470. poNumber: apiSubscription.poNumber ?? '',
  471. termsAndConditions: apiSubscription.termsAndConditions ?? '',
  472. service: 'recurly',
  473. state: apiSubscription.state ?? 'active',
  474. trialPeriodStart: apiSubscription.trialStartedAt,
  475. trialPeriodEnd: apiSubscription.trialEndsAt,
  476. pausePeriodStart: apiSubscription.pausedAt,
  477. remainingPauseCycles: apiSubscription.remainingPauseCycles,
  478. })
  479. if (apiSubscription.pendingChange != null) {
  480. subscription.pendingChange = subscriptionChangeFromApi(
  481. subscription,
  482. apiSubscription.pendingChange
  483. )
  484. }
  485. return subscription
  486. }
  487. /**
  488. * Build a PaymentProviderSubscriptionAddOn from Recurly API data
  489. *
  490. * @param {recurly.SubscriptionAddOn} addOn
  491. * @return {PaymentProviderSubscriptionAddOn}
  492. */
  493. function subscriptionAddOnFromApi(addOn) {
  494. if (
  495. addOn.addOn == null ||
  496. addOn.addOn.code == null ||
  497. addOn.addOn.name == null ||
  498. addOn.unitAmount == null
  499. ) {
  500. throw new OError('Invalid Recurly add-on', { addOn })
  501. }
  502. return new PaymentProviderSubscriptionAddOn({
  503. code: addOn.addOn.code,
  504. name: addOn.addOn.name,
  505. quantity: addOn.quantity ?? 1,
  506. unitPrice: addOn.unitAmount,
  507. })
  508. }
  509. /**
  510. * Build a PaymentProviderSubscriptionChange from Recurly API data
  511. *
  512. * @param {PaymentProviderSubscription} subscription - the current subscription
  513. * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
  514. * @return {PaymentProviderSubscriptionChange}
  515. */
  516. function subscriptionChangeFromApi(subscription, subscriptionChange) {
  517. if (
  518. subscriptionChange.plan == null ||
  519. subscriptionChange.plan.code == null ||
  520. subscriptionChange.plan.name == null ||
  521. subscriptionChange.unitAmount == null
  522. ) {
  523. throw new OError('Invalid Recurly subscription change', {
  524. subscriptionChange,
  525. })
  526. }
  527. const nextAddOns = (subscriptionChange.addOns ?? []).map(
  528. subscriptionAddOnFromApi
  529. )
  530. return new PaymentProviderSubscriptionChange({
  531. subscription,
  532. nextPlanCode: subscriptionChange.plan.code,
  533. nextPlanName: subscriptionChange.plan.name,
  534. nextPlanPrice: subscriptionChange.unitAmount,
  535. nextAddOns,
  536. immediateCharge: computeImmediateCharge(subscriptionChange),
  537. })
  538. }
  539. /**
  540. * Compute immediate charge based on invoice collection
  541. *
  542. * @param {recurly.SubscriptionChange} subscriptionChange - the subscription change returned from the API
  543. * @return {PaymentProviderImmediateCharge}
  544. */
  545. function computeImmediateCharge(subscriptionChange) {
  546. const roundToTwoDecimal = (/** @type {number} */ num) =>
  547. Math.round(num * 100) / 100
  548. let subtotal =
  549. subscriptionChange.invoiceCollection?.chargeInvoice?.subtotal ?? 0
  550. let tax = subscriptionChange.invoiceCollection?.chargeInvoice?.tax ?? 0
  551. let total = subscriptionChange.invoiceCollection?.chargeInvoice?.total ?? 0
  552. let discount =
  553. subscriptionChange.invoiceCollection?.chargeInvoice?.discount ?? 0
  554. const lineItems = []
  555. for (const lineItem of subscriptionChange.invoiceCollection?.chargeInvoice
  556. ?.lineItems || []) {
  557. lineItems.push({
  558. planCode: lineItem.planCode,
  559. isAiAssist:
  560. lineItem.addOnCode === AI_ADD_ON_CODE ||
  561. isStandaloneAiAddOnPlanCode(lineItem.planCode),
  562. description: lineItem.description ?? '',
  563. subtotal: roundToTwoDecimal(lineItem.subtotal ?? 0),
  564. discount: roundToTwoDecimal(lineItem.discount ?? 0),
  565. tax: roundToTwoDecimal(lineItem.tax ?? 0),
  566. })
  567. }
  568. for (const creditInvoice of subscriptionChange.invoiceCollection
  569. ?.creditInvoices ?? []) {
  570. // The credit invoice numbers are already negative
  571. subtotal = roundToTwoDecimal(subtotal + (creditInvoice.subtotal ?? 0))
  572. total = roundToTwoDecimal(total + (creditInvoice.total ?? 0))
  573. // Tax rate can be different in credit invoice if a user relocates
  574. tax = roundToTwoDecimal(tax + (creditInvoice.tax ?? 0))
  575. discount = roundToTwoDecimal(discount + (creditInvoice.discount ?? 0))
  576. for (const lineItem of creditInvoice.lineItems || []) {
  577. lineItems.push({
  578. planCode: lineItem.planCode,
  579. isAiAssist:
  580. lineItem.addOnCode === AI_ADD_ON_CODE ||
  581. isStandaloneAiAddOnPlanCode(lineItem.planCode),
  582. description: lineItem.description ?? '',
  583. subtotal: roundToTwoDecimal(lineItem.subtotal ?? 0),
  584. discount: roundToTwoDecimal(lineItem.discount ?? 0),
  585. tax: roundToTwoDecimal(lineItem.tax ?? 0),
  586. })
  587. }
  588. }
  589. return new PaymentProviderImmediateCharge({
  590. subtotal,
  591. total,
  592. tax,
  593. discount,
  594. lineItems,
  595. })
  596. }
  597. /**
  598. * Returns a payment method from Recurly API data
  599. *
  600. * @param {recurly.BillingInfo} billingInfo
  601. * @return {PaymentMethod}
  602. */
  603. function paymentMethodFromApi(billingInfo) {
  604. if (billingInfo.paymentMethod == null) {
  605. throw new OError('Invalid Recurly billing info', { billingInfo })
  606. }
  607. const paymentMethod = billingInfo.paymentMethod
  608. if (paymentMethod.billingAgreementId != null) {
  609. return new PaypalPaymentMethod()
  610. }
  611. if (paymentMethod.cardType == null || paymentMethod.lastFour == null) {
  612. throw new OError('Invalid Recurly billing info', { billingInfo })
  613. }
  614. return new CreditCardPaymentMethod({
  615. cardType: paymentMethod.cardType,
  616. lastFour: paymentMethod.lastFour,
  617. })
  618. }
  619. /**
  620. * Build a PaymentProviderAddOn from Recurly API data
  621. *
  622. * @param {recurly.AddOn} addOn
  623. * @return {PaymentProviderAddOn}
  624. */
  625. function addOnFromApi(addOn) {
  626. if (addOn.code == null || addOn.name == null) {
  627. throw new OError('Invalid Recurly add-on', { addOn })
  628. }
  629. return new PaymentProviderAddOn({
  630. code: addOn.code,
  631. name: addOn.name,
  632. })
  633. }
  634. /**
  635. * Build a PaymentProviderPlan from Recurly API data
  636. *
  637. * @param {recurly.Plan} plan
  638. * @return {PaymentProviderPlan}
  639. */
  640. function planFromApi(plan) {
  641. if (plan.code == null || plan.name == null) {
  642. throw new OError('Invalid Recurly add-on', { plan })
  643. }
  644. return new PaymentProviderPlan({
  645. code: plan.code,
  646. name: plan.name,
  647. })
  648. }
  649. /**
  650. * Build an API request from a PaymentProviderSubscriptionChangeRequest
  651. *
  652. * @param {PaymentProviderSubscriptionChangeRequest} changeRequest
  653. * @return {recurly.SubscriptionChangeCreate}
  654. */
  655. function subscriptionChangeRequestToApi(changeRequest) {
  656. /** @type {recurly.SubscriptionChangeCreate} */
  657. const requestBody = {
  658. timeframe: changeRequest.timeframe,
  659. }
  660. if (changeRequest.planCode != null) {
  661. requestBody.planCode = changeRequest.planCode
  662. }
  663. if (changeRequest.addOnUpdates != null) {
  664. requestBody.addOns = changeRequest.addOnUpdates.map(addOnUpdate => {
  665. /** @type {recurly.SubscriptionAddOnUpdate} */
  666. const update = { code: addOnUpdate.code }
  667. if (addOnUpdate.quantity != null) {
  668. update.quantity = addOnUpdate.quantity
  669. }
  670. if (addOnUpdate.unitPrice != null) {
  671. update.unitAmount = addOnUpdate.unitPrice
  672. }
  673. return update
  674. })
  675. }
  676. return requestBody
  677. }
  678. /**
  679. * Build an API request from a PaymentProviderSubscriptionUpdateRequest
  680. *
  681. * @param {PaymentProviderSubscriptionUpdateRequest} updateRequest
  682. */
  683. function subscriptionUpdateRequestToApi(updateRequest) {
  684. const requestBody = {}
  685. if (updateRequest.poNumber) {
  686. requestBody.poNumber = updateRequest.poNumber
  687. }
  688. if (updateRequest.termsAndConditions) {
  689. requestBody.termsAndConditions = updateRequest.termsAndConditions
  690. }
  691. return requestBody
  692. }
  693. /**
  694. * Retrieves a list of failed invoices for a given Recurly subscription ID.
  695. *
  696. * @async
  697. * @function
  698. * @param {string} subscriptionId - The ID of the Recurly subscription to fetch failed invoices for.
  699. * @returns {Promise<Array<recurly.Invoice>>} A promise that resolves to an array of failed invoice objects.
  700. */
  701. async function getPastDueInvoices(subscriptionId) {
  702. const failed = []
  703. const invoices = client.listSubscriptionInvoices(`uuid-${subscriptionId}`, {
  704. params: { state: 'past_due' },
  705. })
  706. for await (const invoice of invoices.each()) {
  707. failed.push(invoice)
  708. }
  709. return failed
  710. }
  711. /**
  712. * Marks an invoice as failed using the Recurly client.
  713. *
  714. * @async
  715. * @function failInvoice
  716. * @param {string} invoiceId - The ID of the invoice to be marked as failed.
  717. * @returns {Promise<void>} Resolves when the invoice has been successfully marked as failed.
  718. */
  719. async function failInvoice(invoiceId) {
  720. await client.markInvoiceFailed(invoiceId)
  721. }
  722. async function terminateSubscriptionByUuid(subscriptionUuid) {
  723. const subscription = await client.terminateSubscription(
  724. 'uuid-' + subscriptionUuid,
  725. {
  726. body: {
  727. refund: 'none',
  728. },
  729. }
  730. )
  731. logger.debug({ subscriptionUuid }, 'subscription terminated')
  732. return subscription
  733. }
  734. export default {
  735. errors: recurly.errors,
  736. getAccountForUserId: callbackify(getAccountForUserId),
  737. createAccountForUserId: callbackify(createAccountForUserId),
  738. getActiveCouponsForUserId: callbackify(getActiveCouponsForUserId),
  739. getSubscription: callbackify(getSubscription),
  740. getSubscriptionForUser: callbackify(getSubscriptionForUser),
  741. previewSubscriptionChange: callbackify(previewSubscriptionChange),
  742. updateSubscriptionDetails: callbackify(updateSubscriptionDetails),
  743. applySubscriptionChangeRequest: callbackify(applySubscriptionChangeRequest),
  744. removeSubscriptionChange: callbackify(removeSubscriptionChange),
  745. removeSubscriptionChangeByUuid: callbackify(removeSubscriptionChangeByUuid),
  746. reactivateSubscriptionByUuid: callbackify(reactivateSubscriptionByUuid),
  747. cancelSubscriptionByUuid: callbackify(cancelSubscriptionByUuid),
  748. getPaymentMethod: callbackify(getPaymentMethod),
  749. getAddOn: callbackify(getAddOn),
  750. getPlan: callbackify(getPlan),
  751. subscriptionIsCanceledOrExpired,
  752. pauseSubscriptionByUuid: callbackify(pauseSubscriptionByUuid),
  753. resumeSubscriptionByUuid: callbackify(resumeSubscriptionByUuid),
  754. getPastDueInvoices: callbackify(getPastDueInvoices),
  755. failInvoice: callbackify(failInvoice),
  756. terminateSubscriptionByUuid: callbackify(terminateSubscriptionByUuid),
  757. promises: {
  758. getSubscription,
  759. getSubscriptionForUser,
  760. getAccountForUserId,
  761. createAccountForUserId,
  762. getActiveCouponsForUserId,
  763. getCustomerManagementLink,
  764. previewSubscriptionChange,
  765. updateSubscriptionDetails,
  766. applySubscriptionChangeRequest,
  767. removeSubscriptionChange,
  768. removeSubscriptionChangeByUuid,
  769. reactivateSubscriptionByUuid,
  770. cancelSubscriptionByUuid,
  771. pauseSubscriptionByUuid,
  772. resumeSubscriptionByUuid,
  773. getPaymentMethod,
  774. getAddOn,
  775. getPlan,
  776. getPastDueInvoices,
  777. failInvoice,
  778. terminateSubscriptionByUuid,
  779. },
  780. }