RecurlyClient.test.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import recurly from 'recurly'
  4. import PaymentProviderEntities from '../../../../app/src/Features/Subscription/PaymentProviderEntities.mjs'
  5. const {
  6. PaymentProviderSubscription,
  7. PaymentProviderSubscriptionChangeRequest,
  8. PaymentProviderSubscriptionUpdateRequest,
  9. PaymentProviderSubscriptionAddOnUpdate,
  10. PaymentProviderAccount,
  11. PaymentProviderCoupon,
  12. } = PaymentProviderEntities
  13. const MODULE_PATH = '../../../../app/src/Features/Subscription/RecurlyClient'
  14. describe('RecurlyClient', function () {
  15. beforeEach(async function (ctx) {
  16. ctx.settings = {
  17. apis: {
  18. recurly: {
  19. apiKey: 'nonsense',
  20. privateKey: 'private_nonsense',
  21. subdomain: 'test',
  22. },
  23. },
  24. plans: [],
  25. features: [],
  26. }
  27. ctx.user = { _id: '123456', email: 'joe@example.com', first_name: 'Joe' }
  28. ctx.subscriptionChange = { id: 'subscription-change-123' }
  29. ctx.recurlyAccount = new recurly.Account()
  30. Object.assign(ctx.recurlyAccount, {
  31. code: ctx.user._id,
  32. email: ctx.user.email,
  33. })
  34. ctx.subscriptionAddOn = {
  35. code: 'addon-code',
  36. name: 'My Add-On',
  37. quantity: 1,
  38. unitPrice: 2,
  39. preTaxTotal: 2,
  40. }
  41. ctx.subscription = new PaymentProviderSubscription({
  42. id: 'subscription-id',
  43. userId: 'user-id',
  44. currency: 'EUR',
  45. planCode: 'plan-code',
  46. planName: 'plan-name',
  47. planPrice: 13,
  48. addOns: [ctx.subscriptionAddOn],
  49. subtotal: 15,
  50. taxRate: 0.1,
  51. taxAmount: 1.5,
  52. total: 16.5,
  53. periodStart: new Date(),
  54. periodEnd: new Date(),
  55. collectionMethod: 'automatic',
  56. netTerms: 0,
  57. poNumber: '',
  58. termsAndConditions: '',
  59. })
  60. ctx.recurlySubscription = {
  61. uuid: ctx.subscription.id,
  62. account: {
  63. code: ctx.subscription.userId,
  64. },
  65. plan: {
  66. code: ctx.subscription.planCode,
  67. name: ctx.subscription.planName,
  68. },
  69. addOns: [
  70. {
  71. addOn: {
  72. code: ctx.subscriptionAddOn.code,
  73. name: ctx.subscriptionAddOn.name,
  74. },
  75. quantity: ctx.subscriptionAddOn.quantity,
  76. unitAmount: ctx.subscriptionAddOn.unitPrice,
  77. },
  78. ],
  79. unitAmount: ctx.subscription.planPrice,
  80. subtotal: ctx.subscription.subtotal,
  81. taxInfo: { rate: ctx.subscription.taxRate },
  82. tax: ctx.subscription.taxAmount,
  83. total: ctx.subscription.total,
  84. currency: ctx.subscription.currency,
  85. currentPeriodStartedAt: ctx.subscription.periodStart,
  86. currentPeriodEndsAt: ctx.subscription.periodEnd,
  87. collectionMethod: ctx.subscription.collectionMethod,
  88. netTerms: ctx.subscription.netTerms,
  89. poNumber: ctx.subscription.poNumber,
  90. termsAndConditions: ctx.subscription.termsAndConditions,
  91. }
  92. ctx.recurlySubscriptionChange = new recurly.SubscriptionChange()
  93. Object.assign(ctx.recurlySubscriptionChange, ctx.subscriptionChange)
  94. ctx.UserGetter = {
  95. promises: {
  96. getUser: sinon.stub().callsFake(userId => {
  97. if (userId === ctx.user._id) {
  98. return ctx.user
  99. }
  100. }),
  101. },
  102. }
  103. let client
  104. ctx.client = client = {
  105. getAccount: sinon.stub(),
  106. getBillingInfo: sinon.stub(),
  107. listAccountSubscriptions: sinon.stub(),
  108. listActiveCouponRedemptions: sinon.stub(),
  109. previewSubscriptionChange: sinon.stub(),
  110. listSubscriptionInvoices: sinon.stub(),
  111. }
  112. ctx.recurly = {
  113. errors: recurly.errors,
  114. Client: function () {
  115. return client
  116. },
  117. }
  118. ctx.Errors = {
  119. MissingBillingInfoError: class extends Error {},
  120. SubtotalLimitExceededError: class extends Error {},
  121. }
  122. vi.doMock('@overleaf/settings', () => ({
  123. default: ctx.settings,
  124. }))
  125. vi.doMock('recurly', () => ({
  126. default: ctx.recurly,
  127. }))
  128. vi.doMock('@overleaf/logger', () => ({
  129. default: {
  130. err: sinon.stub(),
  131. error: sinon.stub(),
  132. warn: sinon.stub(),
  133. log: sinon.stub(),
  134. debug: sinon.stub(),
  135. },
  136. }))
  137. vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
  138. default: ctx.UserGetter,
  139. }))
  140. vi.doMock(
  141. '../../../../app/src/Features/Subscription/Errors',
  142. () => ctx.Errors
  143. )
  144. vi.doMock('../../../../app/src/models/Subscription', () => ({}))
  145. ctx.RecurlyClient = (await import(MODULE_PATH)).default
  146. })
  147. describe('initializing recurly client with undefined API key parameter', function () {
  148. it('should create a client without error', function () {
  149. let testClient
  150. expect(() => {
  151. testClient = new recurly.Client(undefined)
  152. }).to.not.throw()
  153. expect(testClient).to.be.instanceOf(recurly.Client)
  154. })
  155. })
  156. describe('getAccountForUserId', function () {
  157. it('should return an Account if one exists', async function (ctx) {
  158. ctx.client.getAccount = sinon.stub().resolves(ctx.recurlyAccount)
  159. const account = await ctx.RecurlyClient.promises.getAccountForUserId(
  160. ctx.user._id
  161. )
  162. const expectedAccount = new PaymentProviderAccount({
  163. code: ctx.user._id,
  164. email: ctx.user.email,
  165. hasPastDueInvoice: false,
  166. })
  167. expect(account).to.deep.equal(expectedAccount)
  168. })
  169. it('should return null if no account found', async function (ctx) {
  170. ctx.client.getAccount = sinon
  171. .stub()
  172. .throws(new recurly.errors.NotFoundError())
  173. const account =
  174. await ctx.RecurlyClient.promises.getAccountForUserId('nonsense')
  175. expect(account).to.equal(null)
  176. })
  177. it('should re-throw caught errors', async function (ctx) {
  178. ctx.client.getAccount = sinon.stub().throws()
  179. await expect(
  180. ctx.RecurlyClient.promises.getAccountForUserId(ctx.user._id)
  181. ).to.eventually.be.rejectedWith(Error)
  182. })
  183. })
  184. describe('createAccountForUserId', function () {
  185. it('should return the Account as created by recurly', async function (ctx) {
  186. ctx.client.createAccount = sinon.stub().resolves(ctx.recurlyAccount)
  187. const result = await ctx.RecurlyClient.promises.createAccountForUserId(
  188. ctx.user._id
  189. )
  190. expect(result).to.has.property('code', ctx.user._id)
  191. })
  192. it('should throw any API errors', async function (ctx) {
  193. ctx.client.createAccount = sinon.stub().throws()
  194. await expect(
  195. ctx.RecurlyClient.promises.createAccountForUserId(ctx.user._id)
  196. ).to.eventually.be.rejectedWith(Error)
  197. })
  198. })
  199. describe('getActiveCouponsForUserId', function () {
  200. it('should return an empty array if no coupons returned', async function (ctx) {
  201. ctx.client.listActiveCouponRedemptions.returns({
  202. each: async function* () {},
  203. })
  204. const coupons =
  205. await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
  206. expect(coupons).to.deep.equal([])
  207. })
  208. it('should return a coupons returned by recurly', async function (ctx) {
  209. const recurlyCoupon = {
  210. coupon: {
  211. code: 'coupon-code',
  212. name: 'Coupon Name',
  213. hostedPageDescription: 'hosted page description',
  214. invoiceDescription: 'invoice description',
  215. },
  216. }
  217. ctx.client.listActiveCouponRedemptions.returns({
  218. each: async function* () {
  219. yield recurlyCoupon
  220. },
  221. })
  222. const coupons =
  223. await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
  224. const expectedCoupons = [
  225. new PaymentProviderCoupon({
  226. code: 'coupon-code',
  227. name: 'Coupon Name',
  228. description: 'hosted page description',
  229. }),
  230. ]
  231. expect(coupons).to.deep.equal(expectedCoupons)
  232. })
  233. it('should not throw for Recurly not found error', async function (ctx) {
  234. ctx.client.listActiveCouponRedemptions = sinon
  235. .stub()
  236. .throws(new recurly.errors.NotFoundError())
  237. const coupons =
  238. await ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
  239. expect(coupons).to.deep.equal([])
  240. })
  241. it('should throw any other API errors', async function (ctx) {
  242. ctx.client.listActiveCouponRedemptions = sinon.stub().throws()
  243. await expect(
  244. ctx.RecurlyClient.promises.getActiveCouponsForUserId('some-user')
  245. ).to.eventually.be.rejectedWith(Error)
  246. })
  247. })
  248. describe('getCustomerManagementLink', function () {
  249. it('should throw if recurly token is not returned', async function (ctx) {
  250. ctx.client.getAccount.resolves({})
  251. await expect(
  252. ctx.RecurlyClient.promises.getCustomerManagementLink(
  253. '12345',
  254. 'account-management',
  255. 'en-US'
  256. )
  257. ).to.be.rejectedWith('recurly account does not have hosted login token')
  258. })
  259. it('should generate the correct account management url', async function (ctx) {
  260. ctx.client.getAccount.resolves({
  261. hostedLoginToken: '987654321',
  262. })
  263. const result = await ctx.RecurlyClient.promises.getCustomerManagementLink(
  264. '12345',
  265. 'account-management',
  266. 'en-US'
  267. )
  268. expect(result).to.equal('https://test.recurly.com/account/987654321')
  269. })
  270. it('should generate the correct billing details url', async function (ctx) {
  271. ctx.client.getAccount.resolves({
  272. hostedLoginToken: '987654321',
  273. })
  274. const result = await ctx.RecurlyClient.promises.getCustomerManagementLink(
  275. '12345',
  276. 'billing-details',
  277. 'en-US'
  278. )
  279. expect(result).to.equal(
  280. 'https://test.recurly.com/account/billing_info/edit?ht=987654321'
  281. )
  282. })
  283. })
  284. describe('getSubscription', function () {
  285. it('should return the subscription found by recurly', async function (ctx) {
  286. ctx.client.getSubscription = sinon
  287. .stub()
  288. .withArgs('uuid-subscription-id')
  289. .resolves(ctx.recurlySubscription)
  290. const subscription = await ctx.RecurlyClient.promises.getSubscription(
  291. ctx.subscription.id
  292. )
  293. expect(subscription).to.deep.equal(ctx.subscription)
  294. })
  295. it('should throw any API errors', async function (ctx) {
  296. ctx.client.getSubscription = sinon.stub().throws()
  297. await expect(
  298. ctx.RecurlyClient.promises.getSubscription(ctx.user._id)
  299. ).to.eventually.be.rejectedWith(Error)
  300. })
  301. })
  302. describe('getSubscriptionForUser', function () {
  303. it("should return null if the account doesn't exist", async function (ctx) {
  304. ctx.client.listAccountSubscriptions.returns({
  305. // eslint-disable-next-line require-yield
  306. each: async function* () {
  307. throw new recurly.errors.NotFoundError('account not found')
  308. },
  309. })
  310. const subscription =
  311. await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
  312. expect(subscription).to.be.null
  313. })
  314. it("should return null if the account doesn't have subscriptions", async function (ctx) {
  315. ctx.client.listAccountSubscriptions.returns({
  316. each: async function* () {},
  317. })
  318. const subscription =
  319. await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
  320. expect(subscription).to.be.null
  321. })
  322. it('should return the subscription if the account has one subscription', async function (ctx) {
  323. const recurlySubscription = ctx.recurlySubscription
  324. ctx.client.listAccountSubscriptions.returns({
  325. each: async function* () {
  326. yield recurlySubscription
  327. },
  328. })
  329. const subscription =
  330. await ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
  331. expect(subscription).to.deep.equal(ctx.subscription)
  332. })
  333. it('should throw an error if the account has more than one subscription', async function (ctx) {
  334. const recurlySubscription = ctx.recurlySubscription
  335. ctx.client.listAccountSubscriptions.returns({
  336. each: async function* () {
  337. yield recurlySubscription
  338. yield { another: 'subscription' }
  339. },
  340. })
  341. await expect(
  342. ctx.RecurlyClient.promises.getSubscriptionForUser('some-user')
  343. ).to.be.rejected
  344. })
  345. })
  346. describe('applySubscriptionChangeRequest', function () {
  347. beforeEach(function (ctx) {
  348. ctx.client.createSubscriptionChange = sinon
  349. .stub()
  350. .resolves(ctx.recurlySubscriptionChange)
  351. })
  352. it('handles plan changes', async function (ctx) {
  353. await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
  354. new PaymentProviderSubscriptionChangeRequest({
  355. subscription: ctx.subscription,
  356. timeframe: 'now',
  357. planCode: 'new-plan',
  358. })
  359. )
  360. expect(ctx.client.createSubscriptionChange).to.be.calledWith(
  361. 'uuid-subscription-id',
  362. { timeframe: 'now', planCode: 'new-plan' }
  363. )
  364. })
  365. it('handles add-on changes', async function (ctx) {
  366. await ctx.RecurlyClient.promises.applySubscriptionChangeRequest(
  367. new PaymentProviderSubscriptionChangeRequest({
  368. subscription: ctx.subscription,
  369. timeframe: 'now',
  370. addOnUpdates: [
  371. new PaymentProviderSubscriptionAddOnUpdate({
  372. code: 'new-add-on',
  373. quantity: 2,
  374. unitPrice: 8.99,
  375. }),
  376. ],
  377. })
  378. )
  379. expect(ctx.client.createSubscriptionChange).to.be.calledWith(
  380. 'uuid-subscription-id',
  381. {
  382. timeframe: 'now',
  383. addOns: [{ code: 'new-add-on', quantity: 2, unitAmount: 8.99 }],
  384. }
  385. )
  386. })
  387. it('should throw any API errors', async function (ctx) {
  388. ctx.client.createSubscriptionChange = sinon.stub().throws()
  389. await expect(
  390. ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
  391. subscription: ctx.subscription,
  392. })
  393. ).to.eventually.be.rejectedWith(Error)
  394. })
  395. it('should throw SubtotalLimitExceededError', async function (ctx) {
  396. class ValidationError extends recurly.errors.ValidationError {
  397. constructor() {
  398. super()
  399. this.params = [{ param: 'subtotal_amount_in_cents' }]
  400. }
  401. }
  402. ctx.client.createSubscriptionChange = sinon
  403. .stub()
  404. .throws(new ValidationError())
  405. await expect(
  406. ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
  407. subscription: ctx.subscription,
  408. })
  409. ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError)
  410. })
  411. it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) {
  412. ctx.client.createSubscriptionChange = sinon.stub().throws(new Error())
  413. await expect(
  414. ctx.RecurlyClient.promises.applySubscriptionChangeRequest({
  415. subscription: ctx.subscription,
  416. })
  417. ).to.be.rejectedWith(Error)
  418. })
  419. })
  420. describe('updateSubscriptionDetails', function () {
  421. beforeEach(function (ctx) {
  422. ctx.client.updateSubscription = sinon
  423. .stub()
  424. .resolves({ id: ctx.subscription.id })
  425. })
  426. it('handles subscription update', async function (ctx) {
  427. await ctx.RecurlyClient.promises.updateSubscriptionDetails(
  428. new PaymentProviderSubscriptionUpdateRequest({
  429. subscription: ctx.subscription,
  430. poNumber: '012345',
  431. termsAndConditions: 'T&C',
  432. })
  433. )
  434. expect(ctx.client.updateSubscription).to.be.calledWith(
  435. 'uuid-subscription-id',
  436. { poNumber: '012345', termsAndConditions: 'T&C' }
  437. )
  438. })
  439. it('should throw any API errors', async function (ctx) {
  440. ctx.client.updateSubscription = sinon.stub().throws()
  441. await expect(
  442. ctx.RecurlyClient.promises.updateSubscriptionDetails({
  443. subscription: ctx.subscription,
  444. })
  445. ).to.eventually.be.rejectedWith(Error)
  446. })
  447. })
  448. describe('removeSubscriptionChange', function () {
  449. beforeEach(function (ctx) {
  450. ctx.client.removeSubscriptionChange = sinon.stub().resolves()
  451. })
  452. it('should attempt to remove a pending subscription change', async function (ctx) {
  453. ctx.RecurlyClient.promises.removeSubscriptionChange(
  454. ctx.subscription.id,
  455. {}
  456. )
  457. expect(ctx.client.removeSubscriptionChange).to.be.calledWith(
  458. ctx.subscription.id
  459. )
  460. })
  461. it('should throw any API errors', async function (ctx) {
  462. ctx.client.removeSubscriptionChange = sinon.stub().throws()
  463. await expect(
  464. ctx.RecurlyClient.promises.removeSubscriptionChange(
  465. ctx.subscription.id,
  466. {}
  467. )
  468. ).to.eventually.be.rejectedWith(Error)
  469. })
  470. describe('removeSubscriptionChangeByUuid', function () {
  471. it('should attempt to remove a pending subscription change', async function (ctx) {
  472. ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid(
  473. ctx.subscription.uuid,
  474. {}
  475. )
  476. expect(ctx.client.removeSubscriptionChange).to.be.calledWith(
  477. 'uuid-' + ctx.subscription.uuid
  478. )
  479. })
  480. it('should throw any API errors', async function (ctx) {
  481. ctx.client.removeSubscriptionChange = sinon.stub().throws()
  482. await expect(
  483. ctx.RecurlyClient.promises.removeSubscriptionChangeByUuid(
  484. ctx.subscription.id,
  485. {}
  486. )
  487. ).to.eventually.be.rejectedWith(Error)
  488. })
  489. })
  490. })
  491. describe('reactivateSubscriptionByUuid', function () {
  492. it('should attempt to reactivate the subscription', async function (ctx) {
  493. ctx.client.reactivateSubscription = sinon
  494. .stub()
  495. .resolves(ctx.recurlySubscription)
  496. const subscription =
  497. await ctx.RecurlyClient.promises.reactivateSubscriptionByUuid(
  498. ctx.subscription.uuid
  499. )
  500. expect(subscription).to.deep.equal(ctx.recurlySubscription)
  501. expect(ctx.client.reactivateSubscription).to.be.calledWith(
  502. 'uuid-' + ctx.subscription.uuid
  503. )
  504. })
  505. })
  506. describe('cancelSubscriptionByUuid', function () {
  507. it('should attempt to cancel the subscription', async function (ctx) {
  508. ctx.client.cancelSubscription = sinon
  509. .stub()
  510. .resolves(ctx.recurlySubscription)
  511. const subscription =
  512. await ctx.RecurlyClient.promises.cancelSubscriptionByUuid(
  513. ctx.subscription.uuid
  514. )
  515. expect(subscription).to.deep.equal(ctx.recurlySubscription)
  516. expect(ctx.client.cancelSubscription).to.be.calledWith(
  517. 'uuid-' + ctx.subscription.uuid
  518. )
  519. })
  520. it('should terminate subscription when cancellation fails due to being in last cycle of paused term', async function (ctx) {
  521. const validationError = new recurly.errors.ValidationError()
  522. validationError.message =
  523. 'Cannot cancel a paused subscription in the last cycle of the term'
  524. ctx.client.cancelSubscription = sinon.stub().throws(validationError)
  525. ctx.client.terminateSubscription = sinon
  526. .stub()
  527. .resolves(ctx.recurlySubscription)
  528. const subscription =
  529. await ctx.RecurlyClient.promises.cancelSubscriptionByUuid(
  530. ctx.subscription.uuid
  531. )
  532. expect(ctx.client.cancelSubscription).to.be.calledWith(
  533. 'uuid-' + ctx.subscription.uuid
  534. )
  535. expect(ctx.client.terminateSubscription).to.be.calledWith(
  536. 'uuid-' + ctx.subscription.uuid
  537. )
  538. expect(subscription).to.deep.equal(ctx.recurlySubscription)
  539. })
  540. })
  541. describe('pauseSubscriptionByUuid', function () {
  542. it('should attempt to pause the subscription', async function (ctx) {
  543. ctx.client.pauseSubscription = sinon
  544. .stub()
  545. .resolves(ctx.recurlySubscription)
  546. const subscription =
  547. await ctx.RecurlyClient.promises.pauseSubscriptionByUuid(
  548. ctx.subscription.uuid,
  549. 3
  550. )
  551. expect(subscription).to.deep.equal(ctx.recurlySubscription)
  552. expect(ctx.client.pauseSubscription).to.be.calledWith(
  553. 'uuid-' + ctx.subscription.uuid,
  554. { remainingPauseCycles: 3 }
  555. )
  556. })
  557. })
  558. describe('previewSubscriptionChange', function () {
  559. describe('compute immediate charge', function () {
  560. it('only has charge invoice', async function (ctx) {
  561. ctx.client.previewSubscriptionChange.resolves({
  562. plan: { code: 'test_code', name: 'test name' },
  563. unitAmount: 0,
  564. invoiceCollection: {
  565. chargeInvoice: {
  566. subtotal: 100,
  567. tax: 20,
  568. total: 120,
  569. },
  570. },
  571. })
  572. const { immediateCharge } =
  573. await ctx.RecurlyClient.promises.previewSubscriptionChange(
  574. new PaymentProviderSubscriptionChangeRequest({
  575. subscription: ctx.subscription,
  576. timeframe: 'now',
  577. planCode: 'new-plan',
  578. })
  579. )
  580. expect(immediateCharge.subtotal).to.be.equal(100)
  581. expect(immediateCharge.tax).to.be.equal(20)
  582. expect(immediateCharge.total).to.be.equal(120)
  583. })
  584. it('credit invoice with imprecise float number', async function (ctx) {
  585. ctx.client.previewSubscriptionChange.resolves({
  586. plan: { code: 'test_code', name: 'test name' },
  587. unitAmount: 0,
  588. invoiceCollection: {
  589. chargeInvoice: {
  590. subtotal: 100.3,
  591. tax: 20.3,
  592. total: 120.3,
  593. },
  594. creditInvoices: [
  595. {
  596. subtotal: -20.1,
  597. tax: -4.1,
  598. total: -24.1,
  599. },
  600. ],
  601. },
  602. })
  603. const { immediateCharge } =
  604. await ctx.RecurlyClient.promises.previewSubscriptionChange(
  605. new PaymentProviderSubscriptionChangeRequest({
  606. subscription: ctx.subscription,
  607. timeframe: 'now',
  608. planCode: 'new-plan',
  609. })
  610. )
  611. expect(immediateCharge.subtotal).to.be.equal(80.2)
  612. expect(immediateCharge.tax).to.be.equal(16.2)
  613. expect(immediateCharge.total).to.be.equal(96.2)
  614. })
  615. })
  616. it('should throw SubtotalLimitExceededError', async function (ctx) {
  617. class ValidationError extends recurly.errors.ValidationError {
  618. constructor() {
  619. super()
  620. this.params = [{ param: 'subtotal_amount_in_cents' }]
  621. }
  622. }
  623. ctx.client.previewSubscriptionChange = sinon
  624. .stub()
  625. .throws(new ValidationError())
  626. await expect(
  627. ctx.RecurlyClient.promises.previewSubscriptionChange(
  628. new PaymentProviderSubscriptionChangeRequest({
  629. subscription: ctx.subscription,
  630. timeframe: 'now',
  631. planCode: 'new-plan',
  632. })
  633. )
  634. ).to.be.rejectedWith(ctx.Errors.SubtotalLimitExceededError)
  635. })
  636. it('should rethrow errors different than SubtotalLimitExceededError', async function (ctx) {
  637. ctx.client.previewSubscriptionChange = sinon.stub().throws(new Error())
  638. await expect(
  639. ctx.RecurlyClient.promises.previewSubscriptionChange(
  640. new PaymentProviderSubscriptionChangeRequest({
  641. subscription: ctx.subscription,
  642. timeframe: 'now',
  643. planCode: 'new-plan',
  644. })
  645. )
  646. ).to.be.rejectedWith(Error)
  647. })
  648. })
  649. describe('getPaymentMethod', function () {
  650. it('should throw MissingBillingInfoError', async function (ctx) {
  651. ctx.client.getBillingInfo = sinon
  652. .stub()
  653. .throws(new recurly.errors.NotFoundError())
  654. await expect(
  655. ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id)
  656. ).to.be.rejectedWith(ctx.Errors.MissingBillingInfoError)
  657. })
  658. it('should rethrow errors different than MissingBillingInfoError', async function (ctx) {
  659. ctx.client.getBillingInfo = sinon.stub().throws(new Error())
  660. await expect(
  661. ctx.RecurlyClient.promises.getPaymentMethod(ctx.user._id)
  662. ).to.be.rejectedWith(Error)
  663. })
  664. })
  665. describe('terminateSubscriptionByUuid', function () {
  666. it('should attempt to terminate the subscription', async function (ctx) {
  667. ctx.client.terminateSubscription = sinon
  668. .stub()
  669. .resolves(ctx.recurlySubscription)
  670. const subscription =
  671. await ctx.RecurlyClient.promises.terminateSubscriptionByUuid(
  672. ctx.subscription.uuid
  673. )
  674. expect(subscription).to.deep.equal(ctx.recurlySubscription)
  675. expect(ctx.client.terminateSubscription).to.be.calledWith(
  676. 'uuid-' + ctx.subscription.uuid
  677. )
  678. })
  679. })
  680. describe('getPastDueInvoices', function () {
  681. beforeEach(function (ctx) {
  682. ctx.client.listSubscriptionInvoices = sinon.stub()
  683. })
  684. it('should return empty if no past due are found', async function (ctx) {
  685. ctx.client.listSubscriptionInvoices.returns({
  686. each: async function* () {},
  687. })
  688. const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices(
  689. ctx.subscription.id
  690. )
  691. expect(invoices).to.deep.equal([])
  692. })
  693. it('should return past due invoice', async function (ctx) {
  694. const pastDueInvoice = { id: 'invoice-1', state: 'past_due' }
  695. ctx.client.listSubscriptionInvoices.returns({
  696. each: async function* () {
  697. yield pastDueInvoice
  698. },
  699. })
  700. const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices(
  701. ctx.subscription.id
  702. )
  703. expect(invoices).to.deep.equal([pastDueInvoice])
  704. })
  705. it('should return multiple invoices if multiple past due exist', async function (ctx) {
  706. const pastDueInvoices = [
  707. { id: 'invoice-1', state: 'past_due' },
  708. { id: 'invoice-2', state: 'past_due' },
  709. ]
  710. ctx.client.listSubscriptionInvoices.returns({
  711. each: async function* () {
  712. for (const invoice of pastDueInvoices) {
  713. yield invoice
  714. }
  715. },
  716. })
  717. const invoices = await ctx.RecurlyClient.promises.getPastDueInvoices(
  718. ctx.subscription.id
  719. )
  720. expect(invoices).to.deep.equal(pastDueInvoices)
  721. })
  722. })
  723. })