RecurlyWrapper.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. const OError = require('@overleaf/o-error')
  2. const {
  3. fetchStringWithResponse,
  4. RequestFailedError,
  5. } = require('@overleaf/fetch-utils')
  6. const Settings = require('@overleaf/settings')
  7. const xml2js = require('xml2js')
  8. const logger = require('@overleaf/logger')
  9. const Errors = require('../Errors/Errors')
  10. const SubscriptionErrors = require('./Errors')
  11. const { callbackify } = require('@overleaf/promise-utils')
  12. /**
  13. * @param accountId
  14. * @param newEmail
  15. */
  16. async function updateAccountEmailAddress(accountId, newEmail) {
  17. const data = {
  18. email: newEmail,
  19. }
  20. let requestBody
  21. try {
  22. requestBody = RecurlyWrapper._buildXml('account', data)
  23. } catch (error) {
  24. throw OError.tag(error, 'error building xml', { accountId, newEmail })
  25. }
  26. const { body } = await RecurlyWrapper.promises.apiRequest({
  27. url: `accounts/${accountId}`,
  28. method: 'PUT',
  29. body: requestBody,
  30. })
  31. return await RecurlyWrapper.promises._parseAccountXml(body)
  32. }
  33. const promises = {
  34. _paypal: {
  35. async checkAccountExists(cache) {
  36. const { user } = cache
  37. logger.debug(
  38. { userId: user._id },
  39. 'checking if recurly account exists for user'
  40. )
  41. let response, body
  42. try {
  43. ;({ response, body } = await RecurlyWrapper.promises.apiRequest({
  44. url: `accounts/${user._id}`,
  45. method: 'GET',
  46. expect404: true,
  47. }))
  48. } catch (error) {
  49. OError.tag(
  50. error,
  51. 'error response from recurly while checking account',
  52. {
  53. user_id: user._id,
  54. }
  55. )
  56. throw error
  57. }
  58. if (response.status === 404) {
  59. // actually not an error in this case, just no existing account
  60. logger.debug(
  61. { userId: user._id },
  62. 'user does not currently exist in recurly, proceed'
  63. )
  64. cache.userExists = false
  65. return cache
  66. }
  67. logger.debug({ userId: user._id }, 'user appears to exist in recurly')
  68. try {
  69. const account = await RecurlyWrapper.promises._parseAccountXml(body)
  70. cache.userExists = true
  71. cache.account = account
  72. return cache
  73. } catch (err) {
  74. OError.tag(err, 'error parsing account', {
  75. user_id: user._id,
  76. })
  77. throw err
  78. }
  79. },
  80. async createAccount(cache) {
  81. const { user } = cache
  82. const { subscriptionDetails } = cache
  83. if (cache.userExists) {
  84. return cache
  85. }
  86. const address = getAddressFromSubscriptionDetails(
  87. subscriptionDetails,
  88. false
  89. )
  90. const data = {
  91. account_code: user._id,
  92. email: user.email,
  93. first_name: user.first_name,
  94. last_name: user.last_name,
  95. address,
  96. }
  97. let requestBody
  98. try {
  99. requestBody = RecurlyWrapper._buildXml('account', data)
  100. } catch (error) {
  101. throw OError.tag(error, 'error building xml', { user_id: user._id })
  102. }
  103. let body
  104. try {
  105. ;({ body } = await RecurlyWrapper.promises.apiRequest({
  106. url: 'accounts',
  107. method: 'POST',
  108. body: requestBody,
  109. }))
  110. } catch (error) {
  111. OError.tag(
  112. error,
  113. 'error response from recurly while creating account',
  114. { user_id: user._id }
  115. )
  116. throw error
  117. }
  118. try {
  119. cache.account = await RecurlyWrapper.promises._parseAccountXml(body)
  120. return cache
  121. } catch (err) {
  122. OError.tag(err, 'error creating account', {
  123. user_id: user._id,
  124. })
  125. throw err
  126. }
  127. },
  128. async createBillingInfo(cache) {
  129. const { user } = cache
  130. const { recurlyTokenIds } = cache
  131. logger.debug({ userId: user._id }, 'creating billing info in recurly')
  132. const accountCode = cache?.account?.account_code
  133. if (!accountCode) {
  134. throw new Error('no account code at createBillingInfo stage')
  135. }
  136. const data = { token_id: recurlyTokenIds.billing }
  137. let requestBody
  138. try {
  139. requestBody = RecurlyWrapper._buildXml('billing_info', data)
  140. } catch (error) {
  141. throw OError.tag(error, 'error building xml', { user_id: user._id })
  142. }
  143. let body
  144. try {
  145. ;({ body } = await RecurlyWrapper.promises.apiRequest({
  146. url: `accounts/${accountCode}/billing_info`,
  147. method: 'POST',
  148. body: requestBody,
  149. }))
  150. } catch (error) {
  151. OError.tag(
  152. error,
  153. 'error response from recurly while creating billing info',
  154. { user_id: user._id }
  155. )
  156. throw error
  157. }
  158. try {
  159. cache.billingInfo =
  160. await RecurlyWrapper.promises._parseBillingInfoXml(body)
  161. return cache
  162. } catch (err) {
  163. OError.tag(err, 'error creating billing info', {
  164. user_id: user._id,
  165. accountCode,
  166. })
  167. throw err
  168. }
  169. },
  170. async setAddressAndCompanyBillingInfo(cache) {
  171. const { user } = cache
  172. const { subscriptionDetails } = cache
  173. logger.debug(
  174. { userId: user._id },
  175. 'setting billing address and company info in recurly'
  176. )
  177. const accountCode = cache?.account?.account_code
  178. if (!accountCode) {
  179. throw new Error(
  180. 'no account code at setAddressAndCompanyBillingInfo stage'
  181. )
  182. }
  183. const addressAndCompanyBillingInfo = getAddressFromSubscriptionDetails(
  184. subscriptionDetails,
  185. true
  186. )
  187. let requestBody
  188. try {
  189. requestBody = RecurlyWrapper._buildXml(
  190. 'billing_info',
  191. addressAndCompanyBillingInfo
  192. )
  193. } catch (error) {
  194. throw OError.tag(error, 'error building xml', { user_id: user._id })
  195. }
  196. let body
  197. try {
  198. ;({ body } = await RecurlyWrapper.promises.apiRequest({
  199. url: `accounts/${accountCode}/billing_info`,
  200. method: 'PUT',
  201. body: requestBody,
  202. }))
  203. } catch (error) {
  204. OError.tag(error, 'error response from recurly while setting address', {
  205. user_id: user._id,
  206. })
  207. throw error
  208. }
  209. try {
  210. cache.billingInfo =
  211. await RecurlyWrapper.promises._parseBillingInfoXml(body)
  212. return cache
  213. } catch (err) {
  214. if (err) {
  215. OError.tag(err, 'error updating billing info', {
  216. user_id: user._id,
  217. })
  218. throw err
  219. }
  220. }
  221. },
  222. async createSubscription(cache) {
  223. const { user } = cache
  224. const { subscriptionDetails } = cache
  225. logger.debug({ userId: user._id }, 'creating subscription in recurly')
  226. const data = {
  227. plan_code: subscriptionDetails.plan_code,
  228. currency: subscriptionDetails.currencyCode,
  229. coupon_code: subscriptionDetails.coupon_code,
  230. account: {
  231. account_code: user._id,
  232. },
  233. }
  234. const customFields =
  235. getCustomFieldsFromSubscriptionDetails(subscriptionDetails)
  236. if (customFields) {
  237. data.custom_fields = customFields
  238. }
  239. let requestBody
  240. try {
  241. requestBody = RecurlyWrapper._buildXml('subscription', data)
  242. } catch (error) {
  243. throw OError.tag(error, 'error building xml', { user_id: user._id })
  244. }
  245. let body
  246. try {
  247. ;({ body } = await RecurlyWrapper.promises.apiRequest({
  248. url: 'subscriptions',
  249. method: 'POST',
  250. body: requestBody,
  251. }))
  252. } catch (error) {
  253. OError.tag(
  254. error,
  255. 'error response from recurly while creating subscription',
  256. { user_id: user._id }
  257. )
  258. throw error
  259. }
  260. try {
  261. cache.subscription =
  262. await RecurlyWrapper.promises._parseSubscriptionXml(body)
  263. return cache
  264. } catch (err) {
  265. OError.tag(err, 'error creating subscription', {
  266. user_id: user._id,
  267. })
  268. throw err
  269. }
  270. },
  271. },
  272. async _createPaypalSubscription(user, subscriptionDetails, recurlyTokenIds) {
  273. logger.debug(
  274. { userId: user._id },
  275. 'starting process of creating paypal subscription'
  276. )
  277. if (subscriptionDetails.subscription_add_ons) {
  278. // TODO: support flexible licensing in paypal flow
  279. const err = new Error('Add-on purchase not supported')
  280. OError.tag(err, 'error in paypal subscription creation process', {
  281. user_id: user._id,
  282. })
  283. throw err
  284. }
  285. // We use waterfall through each of these actions in sequence
  286. // passing a `cache` object along the way. The cache is initialized
  287. // with required data, and `async.apply` to pass the cache to the first function
  288. const cache = { user, recurlyTokenIds, subscriptionDetails }
  289. let result
  290. try {
  291. result = await RecurlyWrapper.promises._paypal.checkAccountExists(cache)
  292. result = await RecurlyWrapper.promises._paypal.createAccount(result)
  293. result = await RecurlyWrapper.promises._paypal.createBillingInfo(result)
  294. result =
  295. await RecurlyWrapper.promises._paypal.setAddressAndCompanyBillingInfo(
  296. result
  297. )
  298. result = await RecurlyWrapper.promises._paypal.createSubscription(result)
  299. } catch (err) {
  300. OError.tag(err, 'error in paypal subscription creation process', {
  301. user_id: user._id,
  302. })
  303. throw err
  304. }
  305. if (!result.subscription) {
  306. const err = new Error('no subscription object in result')
  307. OError.tag(err, 'error in paypal subscription creation process', {
  308. user_id: user._id,
  309. })
  310. throw err
  311. }
  312. logger.debug(
  313. { userId: user._id },
  314. 'done creating paypal subscription for user'
  315. )
  316. return result.subscription
  317. },
  318. async _createCreditCardSubscription(
  319. user,
  320. subscriptionDetails,
  321. recurlyTokenIds
  322. ) {
  323. const data = {
  324. plan_code: subscriptionDetails.plan_code,
  325. currency: subscriptionDetails.currencyCode,
  326. coupon_code: subscriptionDetails.coupon_code,
  327. account: {
  328. account_code: user._id,
  329. email: user.email,
  330. first_name: subscriptionDetails.first_name || user.first_name,
  331. last_name: subscriptionDetails.last_name || user.last_name,
  332. billing_info: {
  333. token_id: recurlyTokenIds.billing,
  334. },
  335. },
  336. }
  337. if (recurlyTokenIds.threeDSecureActionResult) {
  338. data.account.billing_info.three_d_secure_action_result_token_id =
  339. recurlyTokenIds.threeDSecureActionResult
  340. }
  341. if (subscriptionDetails.subscription_add_ons) {
  342. data.subscription_add_ons = subscriptionDetails.subscription_add_ons
  343. }
  344. const customFields =
  345. getCustomFieldsFromSubscriptionDetails(subscriptionDetails)
  346. if (customFields) {
  347. data.custom_fields = customFields
  348. }
  349. let requestBody
  350. try {
  351. requestBody = RecurlyWrapper._buildXml('subscription', data)
  352. } catch (error) {
  353. throw OError.tag(error, 'error building xml', { user_id: user._id })
  354. }
  355. const { response, body } = await RecurlyWrapper.promises.apiRequest({
  356. url: 'subscriptions',
  357. method: 'POST',
  358. body: requestBody,
  359. expect422: true,
  360. })
  361. if (response.status === 422) {
  362. return await RecurlyWrapper.promises._handle422Response(body)
  363. } else {
  364. return await RecurlyWrapper.promises._parseSubscriptionXml(body)
  365. }
  366. },
  367. async createSubscription(user, subscriptionDetails, recurlyTokenIds) {
  368. const { isPaypal } = subscriptionDetails
  369. logger.debug(
  370. { userId: user._id, isPaypal },
  371. 'setting up subscription in recurly'
  372. )
  373. const fn = isPaypal
  374. ? RecurlyWrapper.promises._createPaypalSubscription
  375. : RecurlyWrapper.promises._createCreditCardSubscription
  376. return fn(user, subscriptionDetails, recurlyTokenIds)
  377. },
  378. /**
  379. * @param options - the options to pass to the request library
  380. * @returns {Promise<{ response: unknown, body: string}>}
  381. */
  382. async apiRequest({ expect404, expect422, url, qs, ...fetchOptions }) {
  383. const fetchUrl = new URL(RecurlyWrapper.apiUrl)
  384. fetchUrl.pathname =
  385. fetchUrl.pathname !== '/' ? `${fetchUrl.pathname}/${url}` : url
  386. if (qs) {
  387. for (const [key, value] of Object.entries(qs)) {
  388. fetchUrl.searchParams.set(key, value)
  389. }
  390. }
  391. fetchOptions.headers = {
  392. Authorization: `Basic ${Buffer.from(
  393. Settings.apis.recurly.apiKey
  394. ).toString('base64')}`,
  395. Accept: 'application/xml',
  396. 'Content-Type': 'application/xml; charset=utf-8',
  397. 'X-Api-Version': Settings.apis.recurly.apiVersion,
  398. }
  399. try {
  400. return await fetchStringWithResponse(fetchUrl, fetchOptions)
  401. } catch (error) {
  402. if (error instanceof RequestFailedError) {
  403. if (error.response.status === 404 && expect404) {
  404. return { response: error.response, body: null }
  405. } else if (error.response.status === 422 && expect422) {
  406. return { response: error.response, body: error.body }
  407. }
  408. if (fetchOptions.headers.Authorization) {
  409. fetchOptions.headers.Authorization = 'REDACTED'
  410. }
  411. logger.warn(
  412. {
  413. err: error,
  414. body: error.body,
  415. options: fetchOptions,
  416. url: fetchUrl.href,
  417. statusCode: error.response?.status,
  418. },
  419. 'error returned from recurly'
  420. )
  421. throw new OError(
  422. `Recurly API returned with status code: ${error.response.status}`,
  423. { statusCode: error.response.status }
  424. )
  425. }
  426. }
  427. },
  428. async getSubscriptions(accountId) {
  429. const { body } = await RecurlyWrapper.promises.apiRequest({
  430. url: `accounts/${accountId}/subscriptions`,
  431. })
  432. return await RecurlyWrapper.promises._parseXml(body)
  433. },
  434. async getSubscription(subscriptionId, options) {
  435. let url
  436. if (!options) {
  437. options = {}
  438. }
  439. if (options.recurlyJsResult) {
  440. url = `recurly_js/result/${subscriptionId}`
  441. } else {
  442. url = `subscriptions/${subscriptionId}`
  443. }
  444. const { body } = await RecurlyWrapper.promises.apiRequest({
  445. url,
  446. })
  447. const recurlySubscription =
  448. await RecurlyWrapper.promises._parseSubscriptionXml(body)
  449. if (options.includeAccount) {
  450. let accountId
  451. if (recurlySubscription.account && recurlySubscription.account.url) {
  452. accountId = recurlySubscription.account.url.match(/accounts\/(.*)/)[1]
  453. } else {
  454. throw new Error("I don't understand the response from Recurly")
  455. }
  456. recurlySubscription.account =
  457. await RecurlyWrapper.promises.getAccount(accountId)
  458. return recurlySubscription
  459. } else {
  460. return recurlySubscription
  461. }
  462. },
  463. /**
  464. * @typedef {{getNextPage: () => Promise<PageData>, items: any[]}} PageData
  465. */
  466. async getPaginatedEndpoint(resource, queryParams) {
  467. let allItems = []
  468. let items
  469. /** @type {() => Promise<PageData>} */
  470. let getNextPage = promises.getPaginatedEndpointIterator(
  471. resource,
  472. queryParams
  473. )
  474. while (getNextPage) {
  475. ;({ items, getNextPage } = await getNextPage())
  476. allItems = allItems.concat(items)
  477. logger.debug(`total now ${allItems.length}`)
  478. }
  479. return allItems
  480. },
  481. /**
  482. * @returns {() => Promise<PageData>}
  483. */
  484. getPaginatedEndpointIterator(resource, queryParams) {
  485. queryParams.per_page = queryParams.per_page || 200
  486. const getPage = async (cursor = null) => {
  487. const opts = {
  488. url: resource,
  489. qs: queryParams,
  490. }
  491. if (cursor) {
  492. opts.qs.cursor = cursor
  493. }
  494. const { response, body } = await RecurlyWrapper.promises.apiRequest(opts)
  495. const data = await RecurlyWrapper.promises._parseXml(body)
  496. const items = data[resource]
  497. logger.debug(`got ${items.length} items in this page`)
  498. const match = response.headers.link?.match(/cursor=([0-9.]+%3A[0-9.]+)&/)
  499. const nextCursor = match && match[1]
  500. return {
  501. items,
  502. getNextPage:
  503. nextCursor && (() => getPage(decodeURIComponent(nextCursor))),
  504. }
  505. }
  506. return getPage
  507. },
  508. async getAccount(accountId) {
  509. const { body } = await RecurlyWrapper.promises.apiRequest({
  510. url: `accounts/${accountId}`,
  511. })
  512. return await RecurlyWrapper.promises._parseAccountXml(body)
  513. },
  514. updateAccountEmailAddress,
  515. async getAccountActiveCoupons(accountId) {
  516. const { body } = await RecurlyWrapper.promises.apiRequest({
  517. url: `accounts/${accountId}/redemptions`,
  518. })
  519. const redemptions = await RecurlyWrapper.promises._parseRedemptionsXml(body)
  520. const activeRedemptions = redemptions.filter(
  521. redemption => redemption.state === 'active'
  522. )
  523. const couponCodes = activeRedemptions.map(
  524. redemption => redemption.coupon_code
  525. )
  526. return await Promise.all(
  527. couponCodes.map(couponCode =>
  528. RecurlyWrapper.promises.getCoupon(couponCode)
  529. )
  530. )
  531. },
  532. async getCoupon(couponCode) {
  533. const opts = { url: `coupons/${couponCode}` }
  534. const { body } = await RecurlyWrapper.promises.apiRequest(opts)
  535. return await RecurlyWrapper.promises._parseCouponXml(body)
  536. },
  537. async getBillingInfo(accountId) {
  538. const { body } = await RecurlyWrapper.promises.apiRequest({
  539. url: `accounts/${accountId}/billing_info`,
  540. })
  541. return await RecurlyWrapper.promises._parseXml(body)
  542. },
  543. async getAccountPastDueInvoices(accountId) {
  544. const { body } = await RecurlyWrapper.promises.apiRequest({
  545. url: `accounts/${accountId}/invoices`,
  546. qs: { state: 'past_due' },
  547. })
  548. return await RecurlyWrapper.promises._parseInvoicesXml(body)
  549. },
  550. async attemptInvoiceCollection(invoiceId) {
  551. return await RecurlyWrapper.promises.apiRequest({
  552. url: `invoices/${invoiceId}/collect`,
  553. method: 'PUT',
  554. })
  555. },
  556. async updateSubscription(subscriptionId, options) {
  557. logger.debug(
  558. { subscriptionId, options },
  559. 'telling recurly to update subscription'
  560. )
  561. const data = {
  562. plan_code: options.plan_code,
  563. timeframe: options.timeframe,
  564. }
  565. let requestBody
  566. try {
  567. requestBody = RecurlyWrapper._buildXml('subscription', data)
  568. } catch (error) {
  569. throw OError.tag(error, 'error building xml', { subscriptionId })
  570. }
  571. const { body } = await RecurlyWrapper.promises.apiRequest({
  572. url: `subscriptions/${subscriptionId}`,
  573. method: 'PUT',
  574. body: requestBody,
  575. })
  576. return await RecurlyWrapper.promises._parseSubscriptionXml(body)
  577. },
  578. async createFixedAmountCoupon(
  579. couponCode,
  580. name,
  581. currencyCode,
  582. discountInCents,
  583. planCode
  584. ) {
  585. const data = {
  586. coupon_code: couponCode,
  587. name,
  588. discount_type: 'dollars',
  589. discount_in_cents: {},
  590. plan_codes: {
  591. plan_code: planCode,
  592. },
  593. applies_to_all_plans: false,
  594. }
  595. data.discount_in_cents[currencyCode] = discountInCents
  596. let requestBody
  597. try {
  598. requestBody = RecurlyWrapper._buildXml('coupon', data)
  599. } catch (error) {
  600. throw OError.tag(error, 'error building xml', {
  601. couponCode,
  602. name,
  603. })
  604. }
  605. logger.debug({ couponCode, requestBody }, 'creating coupon')
  606. try {
  607. await RecurlyWrapper.promises.apiRequest({
  608. url: 'coupons',
  609. method: 'POST',
  610. body: requestBody,
  611. })
  612. } catch (error) {
  613. logger.warn({ err: error, couponCode }, 'error creating coupon')
  614. throw error
  615. }
  616. },
  617. async lookupCoupon(couponCode) {
  618. const { body } = await RecurlyWrapper.promises.apiRequest({
  619. url: `coupons/${couponCode}`,
  620. })
  621. return await RecurlyWrapper.promises._parseCouponXml(body)
  622. },
  623. async redeemCoupon(accountCode, couponCode) {
  624. const data = {
  625. account_code: accountCode,
  626. currency: 'USD',
  627. }
  628. let requestBody
  629. try {
  630. requestBody = RecurlyWrapper._buildXml('redemption', data)
  631. } catch (error) {
  632. throw OError.tag(error, 'error building xml', {
  633. accountCode,
  634. couponCode,
  635. })
  636. }
  637. logger.debug(
  638. { accountCode, couponCode, requestBody },
  639. 'redeeming coupon for user'
  640. )
  641. try {
  642. await RecurlyWrapper.promises.apiRequest({
  643. url: `coupons/${couponCode}/redeem`,
  644. method: 'POST',
  645. body: requestBody,
  646. })
  647. } catch (error) {
  648. logger.warn(
  649. { err: error, accountCode, couponCode },
  650. 'error redeeming coupon'
  651. )
  652. throw error
  653. }
  654. },
  655. async extendTrial(subscriptionId, daysUntilExpire) {
  656. if (daysUntilExpire == null) {
  657. daysUntilExpire = 7
  658. }
  659. const nextRenewalDate = new Date()
  660. nextRenewalDate.setDate(nextRenewalDate.getDate() + daysUntilExpire)
  661. logger.debug(
  662. { subscriptionId, daysUntilExpire },
  663. 'Exending Free trial for user'
  664. )
  665. try {
  666. await RecurlyWrapper.promises.apiRequest({
  667. url: `subscriptions/${subscriptionId}/postpone`,
  668. qs: { bulk: false, next_bill_date: nextRenewalDate },
  669. method: 'PUT',
  670. })
  671. } catch (error) {
  672. logger.warn(
  673. { err: error, subscriptionId, daysUntilExpire },
  674. 'error extending trial'
  675. )
  676. throw error
  677. }
  678. },
  679. async listAccountActiveSubscriptions(accountId) {
  680. const { response, body } = await RecurlyWrapper.promises.apiRequest({
  681. url: `accounts/${accountId}/subscriptions`,
  682. qs: {
  683. state: 'active',
  684. },
  685. expect404: true,
  686. })
  687. if (response.status === 404) {
  688. return []
  689. } else {
  690. return await RecurlyWrapper.promises._parseSubscriptionsXml(body)
  691. }
  692. },
  693. async _handle422Response(body) {
  694. const data = await RecurlyWrapper.promises._parseErrorsXml(body)
  695. let errorData = {}
  696. if (data.transaction_error) {
  697. errorData = {
  698. message: data.transaction_error.merchant_message,
  699. info: {
  700. category: data.transaction_error.error_category,
  701. gatewayCode: data.transaction_error.gateway_error_code,
  702. public: {
  703. code: data.transaction_error.error_code,
  704. message: data.transaction_error.customer_message,
  705. },
  706. },
  707. }
  708. if (data.transaction_error.three_d_secure_action_token_id) {
  709. errorData.info.public.threeDSecureActionTokenId =
  710. data.transaction_error.three_d_secure_action_token_id
  711. }
  712. } else if (data.error && data.error._) {
  713. // fallback for errors that don't have a `transaction_error` field, but
  714. // instead a `error` field with a message (e.g. VATMOSS errors)
  715. errorData = {
  716. info: {
  717. public: {
  718. message: data.error._,
  719. },
  720. },
  721. }
  722. }
  723. throw new SubscriptionErrors.RecurlyTransactionError(errorData)
  724. },
  725. async _parseSubscriptionsXml(xml) {
  726. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
  727. xml,
  728. 'subscriptions'
  729. )
  730. },
  731. async _parseSubscriptionXml(xml) {
  732. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
  733. xml,
  734. 'subscription'
  735. )
  736. },
  737. async _parseAccountXml(xml) {
  738. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
  739. xml,
  740. 'account'
  741. )
  742. },
  743. async _parseBillingInfoXml(xml) {
  744. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
  745. xml,
  746. 'billing_info'
  747. )
  748. },
  749. async _parseRedemptionsXml(xml) {
  750. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
  751. xml,
  752. 'redemptions'
  753. )
  754. },
  755. async _parseCouponXml(xml) {
  756. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(xml, 'coupon')
  757. },
  758. async _parseErrorsXml(xml) {
  759. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(xml, 'errors')
  760. },
  761. async _parseInvoicesXml(xml) {
  762. return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
  763. xml,
  764. 'invoices'
  765. )
  766. },
  767. async _parseXmlAndGetAttribute(xml, attribute) {
  768. const data = await RecurlyWrapper.promises._parseXml(xml)
  769. if (data && data[attribute] != null) {
  770. return data[attribute]
  771. } else {
  772. throw new Error("I don't understand the response from Recurly")
  773. }
  774. },
  775. /**
  776. * @param xml
  777. */
  778. _parseXml(xml) {
  779. function convertDataTypes(data) {
  780. let key, value
  781. if (data && data.$) {
  782. if (data.$.nil === 'nil') {
  783. data = null
  784. } else if (data.$.href) {
  785. data.url = data.$.href
  786. delete data.$
  787. } else if (data.$.type === 'integer') {
  788. data = parseInt(data._, 10)
  789. } else if (data.$.type === 'datetime') {
  790. data = new Date(data._)
  791. } else if (data.$.type === 'array') {
  792. delete data.$
  793. let array = []
  794. for (key in data) {
  795. value = data[key]
  796. if (value instanceof Array) {
  797. array = array.concat(convertDataTypes(value))
  798. } else {
  799. array.push(convertDataTypes(value))
  800. }
  801. }
  802. data = array
  803. }
  804. }
  805. if (data instanceof Array) {
  806. data = data.map(entry => convertDataTypes(entry))
  807. } else if (typeof data === 'object') {
  808. for (key in data) {
  809. value = data[key]
  810. data[key] = convertDataTypes(value)
  811. }
  812. }
  813. return data
  814. }
  815. const parser = new xml2js.Parser({
  816. explicitRoot: true,
  817. explicitArray: false,
  818. emptyTag: '',
  819. })
  820. return new Promise((resolve, reject) =>
  821. parser.parseString(xml, function (error, data) {
  822. if (error) {
  823. return reject(error)
  824. }
  825. const result = convertDataTypes(data)
  826. resolve(result)
  827. })
  828. )
  829. },
  830. }
  831. function _buildXml(rootName, data) {
  832. const options = {
  833. headless: true,
  834. renderOpts: {
  835. pretty: true,
  836. indent: '\t',
  837. },
  838. rootName,
  839. }
  840. const builder = new xml2js.Builder(options)
  841. return builder.buildObject(data)
  842. }
  843. const RecurlyWrapper = {
  844. apiUrl: Settings.apis.recurly.url || 'https://api.recurly.com/v2',
  845. _buildXml,
  846. _parseXml: callbackify(promises._parseXml),
  847. createFixedAmountCoupon: callbackify(promises.createFixedAmountCoupon),
  848. getAccountActiveCoupons: callbackify(promises.getAccountActiveCoupons),
  849. getBillingInfo: callbackify(promises.getBillingInfo),
  850. getPaginatedEndpoint: callbackify(promises.getPaginatedEndpoint),
  851. getSubscription: callbackify(promises.getSubscription),
  852. getSubscriptions: callbackify(promises.getSubscriptions),
  853. updateAccountEmailAddress: callbackify(promises.updateAccountEmailAddress),
  854. }
  855. RecurlyWrapper.promises = {
  856. ...promises,
  857. updateAccountEmailAddress,
  858. }
  859. module.exports = RecurlyWrapper
  860. function getCustomFieldsFromSubscriptionDetails(subscriptionDetails) {
  861. if (!subscriptionDetails.ITMCampaign) {
  862. return null
  863. }
  864. const customFields = [
  865. {
  866. name: 'itm_campaign',
  867. value: subscriptionDetails.ITMCampaign,
  868. },
  869. ]
  870. if (subscriptionDetails.ITMContent) {
  871. customFields.push({
  872. name: 'itm_content',
  873. value: subscriptionDetails.ITMContent,
  874. })
  875. }
  876. if (subscriptionDetails.ITMReferrer) {
  877. customFields.push({
  878. name: 'itm_referrer',
  879. value: subscriptionDetails.ITMReferrer,
  880. })
  881. }
  882. return { custom_field: customFields }
  883. }
  884. function getAddressFromSubscriptionDetails(
  885. subscriptionDetails,
  886. includeCompanyInfo
  887. ) {
  888. const { address } = subscriptionDetails
  889. if (!address || !address.country) {
  890. throw new Errors.InvalidError({
  891. message: 'Invalid country',
  892. info: {
  893. public: {
  894. message: 'Invalid country',
  895. },
  896. },
  897. })
  898. }
  899. const addressObject = {
  900. address1: address.address1,
  901. address2: address.address2 || '',
  902. city: address.city || '',
  903. state: address.state || '',
  904. zip: address.zip || '',
  905. country: address.country,
  906. }
  907. if (
  908. includeCompanyInfo &&
  909. subscriptionDetails.billing_info &&
  910. subscriptionDetails.billing_info.company &&
  911. subscriptionDetails.billing_info.company !== ''
  912. ) {
  913. addressObject.company = subscriptionDetails.billing_info.company
  914. if (
  915. subscriptionDetails.billing_info.vat_number &&
  916. subscriptionDetails.billing_info.vat_number !== ''
  917. ) {
  918. addressObject.vat_number = subscriptionDetails.billing_info.vat_number
  919. }
  920. }
  921. return addressObject
  922. }