RecurlyClient.mjs 27 KB

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