RecurlyWrapper.test.mjs 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530
  1. import { vi, assert, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import tk from 'timekeeper'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. import SubscriptionErrors from '../../../../app/src/Features/Subscription/Errors.mjs'
  6. import { RequestFailedError } from '@overleaf/fetch-utils'
  7. const modulePath = '../../../../app/src/Features/Subscription/RecurlyWrapper'
  8. const fixtures = {
  9. 'subscriptions/44f83d7cba354d5b84812419f923ea96':
  10. '<?xml version="1.0" encoding="UTF-8"?>' +
  11. '<subscription href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96">' +
  12. ' <account href="https://api.recurly.com/v2/accounts/104"/>' +
  13. ' <plan href="https://api.recurly.com/v2/plans/gold">' +
  14. ' <plan_code>gold</plan_code>' +
  15. ' <name>Gold plan</name>' +
  16. ' </plan>' +
  17. ' <uuid>44f83d7cba354d5b84812419f923ea96</uuid>' +
  18. ' <state>active</state>' +
  19. ' <unit_amount_in_cents type="integer">800</unit_amount_in_cents>' +
  20. ' <currency>EUR</currency>' +
  21. ' <quantity type="integer">1</quantity>' +
  22. ' <activated_at type="datetime">2011-05-27T07:00:00Z</activated_at>' +
  23. ' <canceled_at nil="nil"></canceled_at>' +
  24. ' <expires_at nil="nil"></expires_at>' +
  25. ' <current_period_started_at type="datetime">2011-06-27T07:00:00Z</current_period_started_at>' +
  26. ' <current_period_ends_at type="datetime">2011-07-27T07:00:00Z</current_period_ends_at>' +
  27. ' <trial_started_at nil="nil"></trial_started_at>' +
  28. ' <trial_ends_at nil="nil"></trial_ends_at>' +
  29. ' <subscription_add_ons type="array">' +
  30. ' <subscription_add_on>' +
  31. ' <add_on_code>ipaddresses</add_on_code>' +
  32. ' <quantity>10</quantity>' +
  33. ' <unit_amount_in_cents>150</unit_amount_in_cents>' +
  34. ' </subscription_add_on>' +
  35. ' </subscription_add_ons>' +
  36. ' <a name="cancel" href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96/cancel" method="put"/>' +
  37. ' <a name="terminate" href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96/terminate" method="put"/>' +
  38. ' <a name="postpone" href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96/postpone" method="put"/>' +
  39. '</subscription>',
  40. 'recurly_js/result/70db44b10f5f4b238669480c9903f6f5':
  41. '<?xml version="1.0" encoding="UTF-8"?>' +
  42. '<subscription href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96">' +
  43. ' <account href="https://api.recurly.com/v2/accounts/104"/>' +
  44. ' <plan href="https://api.recurly.com/v2/plans/gold">' +
  45. ' <plan_code>gold</plan_code>' +
  46. ' <name>Gold plan</name>' +
  47. ' </plan>' +
  48. ' <uuid>44f83d7cba354d5b84812419f923ea96</uuid>' +
  49. ' <state>active</state>' +
  50. ' <unit_amount_in_cents type="integer">800</unit_amount_in_cents>' +
  51. ' <currency>EUR</currency>' +
  52. ' <quantity type="integer">1</quantity>' +
  53. ' <activated_at type="datetime">2011-05-27T07:00:00Z</activated_at>' +
  54. ' <canceled_at nil="nil"></canceled_at>' +
  55. ' <expires_at nil="nil"></expires_at>' +
  56. ' <current_period_started_at type="datetime">2011-06-27T07:00:00Z</current_period_started_at>' +
  57. ' <current_period_ends_at type="datetime">2011-07-27T07:00:00Z</current_period_ends_at>' +
  58. ' <trial_started_at nil="nil"></trial_started_at>' +
  59. ' <trial_ends_at nil="nil"></trial_ends_at>' +
  60. ' <subscription_add_ons type="array">' +
  61. ' <subscription_add_on>' +
  62. ' <add_on_code>ipaddresses</add_on_code>' +
  63. ' <quantity>10</quantity>' +
  64. ' <unit_amount_in_cents>150</unit_amount_in_cents>' +
  65. ' </subscription_add_on>' +
  66. ' </subscription_add_ons>' +
  67. ' <a name="cancel" href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96/cancel" method="put"/>' +
  68. ' <a name="terminate" href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96/terminate" method="put"/>' +
  69. ' <a name="postpone" href="https://api.recurly.com/v2/subscriptions/44f83d7cba354d5b84812419f923ea96/postpone" method="put"/>' +
  70. '</subscription>',
  71. 'accounts/104':
  72. '<?xml version="1.0" encoding="UTF-8"?>' +
  73. '<account href="https://api.recurly.com/v2/accounts/104">' +
  74. ' <adjustments href="https://api.recurly.com/v2/accounts/1/adjustments"/>' +
  75. ' <billing_info href="https://api.recurly.com/v2/accounts/1/billing_info"/>' +
  76. ' <invoices href="https://api.recurly.com/v2/accounts/1/invoices"/>' +
  77. ' <redemption href="https://api.recurly.com/v2/accounts/1/redemption"/>' +
  78. ' <subscriptions href="https://api.recurly.com/v2/accounts/1/subscriptions"/>' +
  79. ' <transactions href="https://api.recurly.com/v2/accounts/1/transactions"/>' +
  80. ' <account_code>104</account_code>' +
  81. ' <state>active</state>' +
  82. ' <username nil="nil"></username>' +
  83. ' <email>verena@example.com</email>' +
  84. ' <first_name>Verena</first_name>' +
  85. ' <last_name>Example</last_name>' +
  86. ' <accept_language nil="nil"></accept_language>' +
  87. ' <hosted_login_token>a92468579e9c4231a6c0031c4716c01d</hosted_login_token>' +
  88. ' <created_at type="datetime">2011-10-25T12:00:00</created_at>' +
  89. '</account>',
  90. }
  91. vi.mock('../../../../app/src/Features/Subscription/Errors', () =>
  92. vi.importActual('../../../../app/src/Features/Subscription/Errors')
  93. )
  94. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  95. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  96. )
  97. const mockApiRequest = function (options) {
  98. if (fixtures[options.url]) {
  99. return {
  100. err: null,
  101. response: { status: 200 },
  102. body: fixtures[options.url],
  103. }
  104. } else {
  105. return {
  106. err: new Error('Not found'),
  107. }
  108. }
  109. }
  110. describe('RecurlyWrapper', function () {
  111. beforeEach(async function (ctx) {
  112. tk.freeze(Date.now()) // freeze the time for these tests
  113. ctx.settings = {
  114. plans: [
  115. {
  116. planCode: 'collaborator',
  117. name: 'Collaborator',
  118. features: {
  119. collaborators: -1,
  120. versioning: true,
  121. },
  122. },
  123. ],
  124. defaultPlanCode: {
  125. collaborators: 0,
  126. versioning: false,
  127. },
  128. apis: {
  129. recurly: {
  130. apiKey: 'nonsense',
  131. privateKey: 'private_nonsense',
  132. },
  133. },
  134. }
  135. ctx.fetchUtils = {
  136. fetchStringWithResponse: sinon.stub(),
  137. RequestFailedError,
  138. }
  139. vi.doMock('@overleaf/settings', () => ({
  140. default: ctx.settings,
  141. }))
  142. vi.doMock('@overleaf/fetch-utils', () => ({
  143. default: ctx.fetchUtils,
  144. }))
  145. ctx.RecurlyWrapper = (await import(modulePath)).default
  146. })
  147. afterEach(function () {
  148. tk.reset()
  149. })
  150. describe('getSubscription', function () {
  151. for (const functionType of ['promise', 'callback']) {
  152. describe(`as ${functionType}`, function () {
  153. beforeEach(function (ctx) {
  154. ctx.recurlySubscription = 'RESET'
  155. ctx.getSubscription = (...params) => {
  156. if (functionType === 'promise') {
  157. return ctx.RecurlyWrapper.promises.getSubscription(...params)
  158. }
  159. if (functionType === 'callback') {
  160. return new Promise((resolve, reject) =>
  161. ctx.RecurlyWrapper.getSubscription(
  162. ...params,
  163. (err, subscription) =>
  164. err ? reject(err) : resolve(subscription)
  165. )
  166. )
  167. }
  168. throw Error('Invalid function type')
  169. }
  170. })
  171. describe('with proper subscription id', function () {
  172. beforeEach(async function (ctx) {
  173. ctx.apiRequest = sinon
  174. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  175. .callsFake(mockApiRequest)
  176. ctx.recurlySubscription = await ctx.getSubscription(
  177. '44f83d7cba354d5b84812419f923ea96'
  178. )
  179. })
  180. afterEach(function (ctx) {
  181. ctx.RecurlyWrapper.promises.apiRequest.restore()
  182. })
  183. it('should look up the subscription at the normal API end point', function (ctx) {
  184. ctx.apiRequest.args[0][0].url.should.equal(
  185. 'subscriptions/44f83d7cba354d5b84812419f923ea96'
  186. )
  187. })
  188. it('should return the subscription', function (ctx) {
  189. ctx.recurlySubscription.uuid.should.equal(
  190. '44f83d7cba354d5b84812419f923ea96'
  191. )
  192. })
  193. })
  194. describe('with RecurlyJS token', function () {
  195. beforeEach(async function (ctx) {
  196. ctx.apiRequest = sinon
  197. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  198. .callsFake(mockApiRequest)
  199. ctx.recurlySubscription = await ctx.getSubscription(
  200. '70db44b10f5f4b238669480c9903f6f5',
  201. { recurlyJsResult: true }
  202. )
  203. })
  204. afterEach(function (ctx) {
  205. ctx.RecurlyWrapper.promises.apiRequest.restore()
  206. })
  207. it('should return the subscription', function (ctx) {
  208. ctx.recurlySubscription.uuid.should.equal(
  209. '44f83d7cba354d5b84812419f923ea96'
  210. )
  211. })
  212. it('should look up the subscription at the RecurlyJS API end point', function (ctx) {
  213. ctx.apiRequest.args[0][0].url.should.equal(
  214. 'recurly_js/result/70db44b10f5f4b238669480c9903f6f5'
  215. )
  216. })
  217. })
  218. describe('with includeAccount', function () {
  219. beforeEach(async function (ctx) {
  220. ctx.apiRequest = sinon
  221. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  222. .callsFake(mockApiRequest)
  223. ctx.recurlySubscription = await ctx.getSubscription(
  224. '44f83d7cba354d5b84812419f923ea96',
  225. { includeAccount: true }
  226. )
  227. })
  228. afterEach(function (ctx) {
  229. ctx.RecurlyWrapper.promises.apiRequest.restore()
  230. })
  231. it('should request the account from the API', function (ctx) {
  232. ctx.apiRequest.args[1][0].url.should.equal('accounts/104')
  233. })
  234. it('should populate the account attribute', function (ctx) {
  235. ctx.recurlySubscription.account.account_code.should.equal('104')
  236. })
  237. })
  238. })
  239. }
  240. })
  241. describe('updateAccountEmailAddress', function () {
  242. beforeEach(async function (ctx) {
  243. ctx.recurlyAccountId = 'account-id-123'
  244. ctx.newEmail = 'example@overleaf.com'
  245. ctx.apiRequest = sinon
  246. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  247. .callsFake(options => {
  248. ctx.requestOptions = options
  249. return {
  250. err: null,
  251. response: {},
  252. body: fixtures['accounts/104'],
  253. }
  254. })
  255. ctx.recurlyAccount =
  256. await ctx.RecurlyWrapper.promises.updateAccountEmailAddress(
  257. ctx.recurlyAccountId,
  258. ctx.newEmail
  259. )
  260. })
  261. afterEach(function (ctx) {
  262. ctx.RecurlyWrapper.promises.apiRequest.restore()
  263. })
  264. it('sends correct XML', function (ctx) {
  265. ctx.apiRequest.called.should.equal(true)
  266. const { body } = ctx.apiRequest.lastCall.args[0]
  267. expect(body).to.equal(`\
  268. <account>
  269. <email>example@overleaf.com</email>
  270. </account>\
  271. `)
  272. ctx.requestOptions.url.should.equal(`accounts/${ctx.recurlyAccountId}`)
  273. ctx.requestOptions.method.should.equal('PUT')
  274. })
  275. it('should return the updated account', function (ctx) {
  276. expect(ctx.recurlyAccount).to.exist
  277. ctx.recurlyAccount.account_code.should.equal('104')
  278. })
  279. })
  280. describe('updateAccountEmailAddress, with invalid XML', function () {
  281. beforeEach(async function (ctx) {
  282. ctx.recurlyAccountId = 'account-id-123'
  283. ctx.newEmail = '\uD800@example.com'
  284. ctx.apiRequest = sinon
  285. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  286. .callsFake(options => {
  287. ctx.requestOptions = options
  288. return {
  289. err: null,
  290. response: {},
  291. body: fixtures['accounts/104'],
  292. }
  293. })
  294. })
  295. afterEach(function (ctx) {
  296. ctx.RecurlyWrapper.promises.apiRequest.restore()
  297. })
  298. it('should produce an error', async function (ctx) {
  299. try {
  300. await ctx.RecurlyWrapper.promises.updateAccountEmailAddress(
  301. ctx.recurlyAccountId,
  302. ctx.newEmail
  303. )
  304. assert.fail('Expected error not thrown')
  305. } catch (error) {
  306. expect(error).to.have.property('message')
  307. expect(error.message.startsWith('Invalid character')).to.be.true
  308. expect(ctx.apiRequest.called).to.equal(false)
  309. }
  310. })
  311. })
  312. describe('updateSubscription', function () {
  313. beforeEach(async function (ctx) {
  314. ctx.recurlySubscriptionId = 'subscription-id-123'
  315. ctx.apiRequest = sinon
  316. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  317. .callsFake(options => {
  318. ctx.requestOptions = options
  319. return {
  320. error: null,
  321. response: {},
  322. body: fixtures['subscriptions/44f83d7cba354d5b84812419f923ea96'],
  323. }
  324. })
  325. ctx.recurlySubscription =
  326. await ctx.RecurlyWrapper.promises.updateSubscription(
  327. ctx.recurlySubscriptionId,
  328. { plan_code: 'silver', timeframe: 'now' }
  329. )
  330. })
  331. afterEach(function (ctx) {
  332. ctx.RecurlyWrapper.promises.apiRequest.restore()
  333. })
  334. it('sends correct XML', function (ctx) {
  335. ctx.apiRequest.called.should.equal(true)
  336. const { body } = ctx.apiRequest.lastCall.args[0]
  337. expect(body).to.equal(`\
  338. <subscription>
  339. <plan_code>silver</plan_code>
  340. <timeframe>now</timeframe>
  341. </subscription>\
  342. `)
  343. ctx.requestOptions.url.should.equal(
  344. `subscriptions/${ctx.recurlySubscriptionId}`
  345. )
  346. ctx.requestOptions.method.should.equal('PUT')
  347. })
  348. it('should return the updated subscription', function (ctx) {
  349. expect(ctx.recurlySubscription).to.exist
  350. ctx.recurlySubscription.plan.plan_code.should.equal('gold')
  351. })
  352. })
  353. describe('redeemCoupon', function () {
  354. beforeEach(async function (ctx) {
  355. ctx.recurlyAccountId = 'account-id-123'
  356. ctx.coupon_code = '312321312'
  357. ctx.apiRequest = sinon
  358. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  359. .callsFake(options => {
  360. options.url.should.equal(`coupons/${ctx.coupon_code}/redeem`)
  361. options.method.should.equal('POST')
  362. return {}
  363. })
  364. await ctx.RecurlyWrapper.promises.redeemCoupon(
  365. ctx.recurlyAccountId,
  366. ctx.coupon_code
  367. )
  368. })
  369. afterEach(function (ctx) {
  370. ctx.RecurlyWrapper.promises.apiRequest.restore()
  371. })
  372. it('sends correct XML', function (ctx) {
  373. ctx.apiRequest.called.should.equal(true)
  374. const { body } = ctx.apiRequest.lastCall.args[0]
  375. expect(body).to.equal(`\
  376. <redemption>
  377. <account_code>account-id-123</account_code>
  378. <currency>USD</currency>
  379. </redemption>\
  380. `)
  381. })
  382. })
  383. describe('createFixedAmountCoupon', function () {
  384. beforeEach(async function (ctx) {
  385. ctx.couponCode = 'a-coupon-code'
  386. ctx.couponName = 'a-coupon-name'
  387. ctx.currencyCode = 'EUR'
  388. ctx.discount = 1337
  389. ctx.planCode = 'a-plan-code'
  390. ctx.apiRequest = sinon
  391. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  392. .resolves()
  393. await ctx.RecurlyWrapper.promises.createFixedAmountCoupon(
  394. ctx.couponCode,
  395. ctx.couponName,
  396. ctx.currencyCode,
  397. ctx.discount,
  398. ctx.planCode
  399. )
  400. })
  401. afterEach(function (ctx) {
  402. ctx.RecurlyWrapper.promises.apiRequest.restore()
  403. })
  404. it('sends correct XML', function (ctx) {
  405. ctx.apiRequest.called.should.equal(true)
  406. const { body } = ctx.apiRequest.lastCall.args[0]
  407. expect(body).to.equal(`\
  408. <coupon>
  409. <coupon_code>a-coupon-code</coupon_code>
  410. <name>a-coupon-name</name>
  411. <discount_type>dollars</discount_type>
  412. <discount_in_cents>
  413. <EUR>1337</EUR>
  414. </discount_in_cents>
  415. <plan_codes>
  416. <plan_code>a-plan-code</plan_code>
  417. </plan_codes>
  418. <applies_to_all_plans>false</applies_to_all_plans>
  419. </coupon>\
  420. `)
  421. })
  422. })
  423. describe('createSubscription', function () {
  424. beforeEach(function (ctx) {
  425. ctx.user = {
  426. _id: 'some_id',
  427. email: 'user@example.com',
  428. }
  429. ctx.subscriptionDetails = {
  430. currencyCode: 'EUR',
  431. plan_code: 'some_plan_code',
  432. coupon_code: '',
  433. isPaypal: true,
  434. address: {
  435. address1: 'addr_one',
  436. address2: 'addr_two',
  437. country: 'some_country',
  438. state: 'some_state',
  439. zip: 'some_zip',
  440. },
  441. }
  442. ctx.subscription = {}
  443. ctx.recurlyTokenIds = {
  444. billing: 'a-token-id',
  445. threeDSecureActionResult: 'a-3d-token-id',
  446. }
  447. ctx.call = () => {
  448. return ctx.RecurlyWrapper.promises.createSubscription(
  449. ctx.user,
  450. ctx.subscriptionDetails,
  451. ctx.recurlyTokenIds
  452. )
  453. }
  454. })
  455. describe('when paypal', function () {
  456. beforeEach(function (ctx) {
  457. ctx.subscriptionDetails.isPaypal = true
  458. ctx._createPaypalSubscription = sinon.stub(
  459. ctx.RecurlyWrapper.promises,
  460. '_createPaypalSubscription'
  461. )
  462. ctx._createPaypalSubscription.resolves(ctx.subscription)
  463. })
  464. afterEach(function (ctx) {
  465. ctx._createPaypalSubscription.restore()
  466. })
  467. it('should not produce an error', async function (ctx) {
  468. await expect(ctx.call()).to.be.fulfilled
  469. })
  470. it('should produce a subscription object', async function (ctx) {
  471. const sub = await ctx.call()
  472. expect(sub).to.deep.equal(ctx.subscription)
  473. })
  474. it('should call _createPaypalSubscription', async function (ctx) {
  475. await ctx.call()
  476. ctx._createPaypalSubscription.callCount.should.equal(1)
  477. })
  478. describe('when _createPaypalSubscription produces an error', function () {
  479. beforeEach(function (ctx) {
  480. ctx._createPaypalSubscription.rejects(new Error('woops'))
  481. })
  482. it('should produce an error', async function (ctx) {
  483. await expect(ctx.call()).to.be.rejectedWith('woops')
  484. })
  485. })
  486. })
  487. describe('when not paypal', function () {
  488. beforeEach(function (ctx) {
  489. ctx.subscriptionDetails.isPaypal = false
  490. ctx._createCreditCardSubscription = sinon.stub(
  491. ctx.RecurlyWrapper.promises,
  492. '_createCreditCardSubscription'
  493. )
  494. ctx._createCreditCardSubscription.resolves(ctx.subscription)
  495. })
  496. afterEach(function (ctx) {
  497. ctx._createCreditCardSubscription.restore()
  498. })
  499. it('should not produce an error', async function (ctx) {
  500. await ctx.call()
  501. })
  502. it('should produce a subscription object', async function (ctx) {
  503. const sub = await ctx.call()
  504. expect(sub).to.deep.equal(ctx.subscription)
  505. })
  506. it('should call _createCreditCardSubscription', async function (ctx) {
  507. await ctx.call()
  508. ctx._createCreditCardSubscription.callCount.should.equal(1)
  509. })
  510. describe('when _createCreditCardSubscription produces an error', function () {
  511. beforeEach(function (ctx) {
  512. ctx._createCreditCardSubscription.rejects(new Error('woops'))
  513. })
  514. it('should produce an error', async function (ctx) {
  515. await expect(ctx.call()).to.be.rejectedWith('woops')
  516. })
  517. })
  518. })
  519. })
  520. describe('_createCreditCardSubscription', function () {
  521. beforeEach(function (ctx) {
  522. ctx.user = {
  523. _id: 'some_id',
  524. email: 'user@example.com',
  525. first_name: 'Foo',
  526. last_name: 'Johnson',
  527. }
  528. ctx.subscriptionDetails = {
  529. currencyCode: 'EUR',
  530. plan_code: 'some_plan_code',
  531. coupon_code: '',
  532. isPaypal: true,
  533. first_name: 'Prairie',
  534. address: {
  535. address1: 'addr_one',
  536. address2: 'addr_two',
  537. country: 'some_country',
  538. state: 'some_state',
  539. zip: 'some_zip',
  540. },
  541. subscription_add_ons: [
  542. { subscription_add_on: { add_on_code: 'test_add_on', quantity: 2 } },
  543. ],
  544. ITMCampaign: 'itm-campaign-value',
  545. ITMContent: 'itm-content-value',
  546. ITMReferrer: 'itm-referrer-value',
  547. }
  548. ctx.subscription = {}
  549. ctx.recurlyTokenIds = {
  550. billing: 'a-token-id',
  551. threeDSecureActionResult: 'a-3d-token-id',
  552. }
  553. ctx.apiRequest = sinon.stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  554. ctx.response = { status: 200 }
  555. ctx.body = '<xml>is_bad</xml>'
  556. ctx.apiRequest.resolves({
  557. response: ctx.response,
  558. body: ctx.body,
  559. })
  560. ctx._parseSubscriptionXml = sinon.stub(
  561. ctx.RecurlyWrapper.promises,
  562. '_parseSubscriptionXml'
  563. )
  564. ctx._parseSubscriptionXml.resolves(ctx.subscription)
  565. ctx.call = () => {
  566. return ctx.RecurlyWrapper.promises._createCreditCardSubscription(
  567. ctx.user,
  568. ctx.subscriptionDetails,
  569. ctx.recurlyTokenIds
  570. )
  571. }
  572. })
  573. afterEach(function (ctx) {
  574. ctx.apiRequest.restore()
  575. ctx._parseSubscriptionXml.restore()
  576. })
  577. it('sends correct XML', async function (ctx) {
  578. await ctx.call()
  579. const { body } = ctx.apiRequest.lastCall.args[0]
  580. expect(body).to.equal(`\
  581. <subscription>
  582. <plan_code>some_plan_code</plan_code>
  583. <currency>EUR</currency>
  584. <coupon_code/>
  585. <account>
  586. <account_code>some_id</account_code>
  587. <email>user@example.com</email>
  588. <first_name>Prairie</first_name>
  589. <last_name>Johnson</last_name>
  590. <billing_info>
  591. <token_id>a-token-id</token_id>
  592. <three_d_secure_action_result_token_id>a-3d-token-id</three_d_secure_action_result_token_id>
  593. </billing_info>
  594. </account>
  595. <subscription_add_ons>
  596. <subscription_add_on>
  597. <add_on_code>test_add_on</add_on_code>
  598. <quantity>2</quantity>
  599. </subscription_add_on>
  600. </subscription_add_ons>
  601. <custom_fields>
  602. <custom_field>
  603. <name>itm_campaign</name>
  604. <value>itm-campaign-value</value>
  605. </custom_field>
  606. <custom_field>
  607. <name>itm_content</name>
  608. <value>itm-content-value</value>
  609. </custom_field>
  610. <custom_field>
  611. <name>itm_referrer</name>
  612. <value>itm-referrer-value</value>
  613. </custom_field>
  614. </custom_fields>
  615. </subscription>\
  616. `)
  617. })
  618. it('should not produce an error', async function (ctx) {
  619. await expect(ctx.call()).to.be.fulfilled
  620. })
  621. it('should produce a subscription', async function (ctx) {
  622. const sub = await ctx.call()
  623. expect(sub).to.equal(ctx.subscription)
  624. })
  625. it('should call apiRequest', async function (ctx) {
  626. await ctx.call()
  627. ctx.apiRequest.callCount.should.equal(1)
  628. })
  629. it('should call _parseSubscriptionXml', async function (ctx) {
  630. await ctx.call()
  631. ctx._parseSubscriptionXml.callCount.should.equal(1)
  632. })
  633. describe('when api request returns 422', function () {
  634. beforeEach(function (ctx) {
  635. const body = `\
  636. <?xml version="1.0" encoding="UTF-8"?>
  637. <errors>
  638. <transaction_error>
  639. <error_code>three_d_secure_action_required</error_code>
  640. <error_category>3d_secure_action_required</error_category>
  641. <merchant_message>Your payment gateway is requesting that the transaction be completed with 3D Secure in accordance with PSD2.</merchant_message>
  642. <customer_message>Your card must be authenticated with 3D Secure before continuing.</customer_message>
  643. <gateway_error_code nil="nil"></gateway_error_code>
  644. <three_d_secure_action_token_id>mock_three_d_secure_action_token</three_d_secure_action_token_id>
  645. </transaction_error>
  646. <error field="subscription.account.base" symbol="three_d_secure_action_required">Your card must be authenticated with 3D Secure before continuing.</error>
  647. </errors>
  648. `
  649. // this.apiRequest.yields(null, { statusCode: 422 }, body)
  650. ctx.apiRequest.resolves({
  651. response: { status: 422 },
  652. body,
  653. })
  654. })
  655. it('should produce an error', async function (ctx) {
  656. const promise = ctx.call()
  657. let error
  658. try {
  659. await promise
  660. } catch (err) {
  661. error = err
  662. }
  663. expect(error).to.be.instanceOf(
  664. SubscriptionErrors.RecurlyTransactionError
  665. )
  666. expect(error).to.have.nested.property(
  667. 'info.public.message',
  668. 'Your card must be authenticated with 3D Secure before continuing.'
  669. )
  670. expect(error).to.have.nested.property(
  671. 'info.public.threeDSecureActionTokenId',
  672. 'mock_three_d_secure_action_token'
  673. )
  674. })
  675. })
  676. describe('when api request produces an error', function () {
  677. beforeEach(function (ctx) {
  678. ctx.apiRequest.rejects(new Error('woops'))
  679. })
  680. it('should produce an error', async function (ctx) {
  681. await expect(ctx.call()).to.be.rejectedWith('woops')
  682. })
  683. it('should call apiRequest', async function (ctx) {
  684. await expect(ctx.call()).to.be.rejected
  685. ctx.apiRequest.callCount.should.equal(1)
  686. })
  687. it('should not _parseSubscriptionXml', async function (ctx) {
  688. await expect(ctx.call()).to.be.rejected
  689. ctx._parseSubscriptionXml.callCount.should.equal(0)
  690. })
  691. })
  692. describe('when parse xml produces an error', function () {
  693. beforeEach(function (ctx) {
  694. ctx._parseSubscriptionXml.rejects(new Error('woops xml'))
  695. })
  696. it('should produce an error', async function (ctx) {
  697. await expect(ctx.call()).to.be.rejectedWith('woops xml')
  698. })
  699. })
  700. })
  701. describe('_createPaypalSubscription', function () {
  702. beforeEach(function (ctx) {
  703. ctx.checkAccountExists = sinon.stub(
  704. ctx.RecurlyWrapper.promises._paypal,
  705. 'checkAccountExists'
  706. )
  707. ctx.createAccount = sinon.stub(
  708. ctx.RecurlyWrapper.promises._paypal,
  709. 'createAccount'
  710. )
  711. ctx.createBillingInfo = sinon.stub(
  712. ctx.RecurlyWrapper.promises._paypal,
  713. 'createBillingInfo'
  714. )
  715. ctx.setAddressAndCompanyBillingInfo = sinon.stub(
  716. ctx.RecurlyWrapper.promises._paypal,
  717. 'setAddressAndCompanyBillingInfo'
  718. )
  719. ctx.createSubscription = sinon.stub(
  720. ctx.RecurlyWrapper.promises._paypal,
  721. 'createSubscription'
  722. )
  723. ctx.user = {
  724. _id: 'some_id',
  725. email: 'user@example.com',
  726. }
  727. ctx.subscriptionDetails = {
  728. currencyCode: 'EUR',
  729. plan_code: 'some_plan_code',
  730. coupon_code: '',
  731. isPaypal: true,
  732. address: {
  733. address1: 'addr_one',
  734. address2: 'addr_two',
  735. country: 'some_country',
  736. state: 'some_state',
  737. zip: 'some_zip',
  738. },
  739. }
  740. ctx.subscription = {}
  741. ctx.recurlyTokenIds = {
  742. billing: 'a-token-id',
  743. threeDSecureActionResult: 'a-3d-token-id',
  744. }
  745. // set up data callbacks
  746. const { user } = ctx
  747. const { subscriptionDetails } = ctx
  748. const { recurlyTokenIds } = ctx
  749. ctx.checkAccountExists.resolves({
  750. user,
  751. subscriptionDetails,
  752. recurlyTokenIds,
  753. userExists: false,
  754. account: { accountCode: 'xx' },
  755. })
  756. ctx.createAccount.resolves({
  757. user,
  758. subscriptionDetails,
  759. recurlyTokenIds,
  760. userExists: false,
  761. account: { accountCode: 'xx' },
  762. })
  763. ctx.createBillingInfo.resolves({
  764. user,
  765. subscriptionDetails,
  766. recurlyTokenIds,
  767. userExists: false,
  768. account: { accountCode: 'xx' },
  769. billingInfo: { token_id: 'abc' },
  770. })
  771. ctx.setAddressAndCompanyBillingInfo.resolves({
  772. user,
  773. subscriptionDetails,
  774. recurlyTokenIds,
  775. userExists: false,
  776. account: { accountCode: 'xx' },
  777. billingInfo: { token_id: 'abc' },
  778. })
  779. ctx.createSubscription.resolves({
  780. user,
  781. subscriptionDetails,
  782. recurlyTokenIds,
  783. userExists: false,
  784. account: { accountCode: 'xx' },
  785. billingInfo: { token_id: 'abc' },
  786. subscription: ctx.subscription,
  787. })
  788. ctx.call = () => {
  789. return ctx.RecurlyWrapper.promises._createPaypalSubscription(
  790. ctx.user,
  791. ctx.subscriptionDetails,
  792. ctx.recurlyTokenIds
  793. )
  794. }
  795. })
  796. afterEach(function (ctx) {
  797. ctx.checkAccountExists.restore()
  798. ctx.createAccount.restore()
  799. ctx.createBillingInfo.restore()
  800. ctx.setAddressAndCompanyBillingInfo.restore()
  801. ctx.createSubscription.restore()
  802. })
  803. it('should not produce an error', async function (ctx) {
  804. await expect(ctx.call()).to.be.fulfilled
  805. })
  806. it('should produce a subscription object', async function (ctx) {
  807. const sub = await ctx.call()
  808. expect(sub).to.not.equal(null)
  809. expect(sub).to.equal(ctx.subscription)
  810. })
  811. it('should call each of the paypal stages', async function (ctx) {
  812. await ctx.call()
  813. ctx.checkAccountExists.callCount.should.equal(1)
  814. ctx.createAccount.callCount.should.equal(1)
  815. ctx.createBillingInfo.callCount.should.equal(1)
  816. ctx.setAddressAndCompanyBillingInfo.callCount.should.equal(1)
  817. ctx.createSubscription.callCount.should.equal(1)
  818. })
  819. describe('when one of the paypal stages produces an error', function () {
  820. beforeEach(function (ctx) {
  821. ctx.createAccount.rejects(new Error('woops'))
  822. })
  823. it('should produce an error', async function (ctx) {
  824. await expect(ctx.call()).to.be.rejectedWith('woops')
  825. })
  826. it('should stop calling the paypal stages after the error', async function (ctx) {
  827. await expect(ctx.call()).to.be.rejected
  828. ctx.checkAccountExists.callCount.should.equal(1)
  829. ctx.createAccount.callCount.should.equal(1)
  830. ctx.createBillingInfo.callCount.should.equal(0)
  831. ctx.setAddressAndCompanyBillingInfo.callCount.should.equal(0)
  832. ctx.createSubscription.callCount.should.equal(0)
  833. })
  834. })
  835. })
  836. describe('paypal actions', function () {
  837. beforeEach(function (ctx) {
  838. ctx.apiRequest = sinon.stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  839. ctx._parseAccountXml = sinon.spy(
  840. ctx.RecurlyWrapper.promises,
  841. '_parseAccountXml'
  842. )
  843. ctx._parseBillingInfoXml = sinon.spy(
  844. ctx.RecurlyWrapper.promises,
  845. '_parseBillingInfoXml'
  846. )
  847. ctx._parseSubscriptionXml = sinon.spy(
  848. ctx.RecurlyWrapper.promises,
  849. '_parseSubscriptionXml'
  850. )
  851. ctx.cache = {
  852. user: (ctx.user = {
  853. _id: 'some_id',
  854. email: 'foo@bar.com',
  855. first_name: 'Foo',
  856. last_name: 'Bar',
  857. }),
  858. recurlyTokenIds: (ctx.recurlyTokenIds = {
  859. billing: 'a-token-id',
  860. threeDSecureActionResult: 'a-3d-token-id',
  861. }),
  862. subscriptionDetails: (ctx.subscriptionDetails = {
  863. currencyCode: 'EUR',
  864. plan_code: 'some_plan_code',
  865. coupon_code: '',
  866. isPaypal: true,
  867. address: {
  868. address1: 'addr_one',
  869. address2: 'addr_two',
  870. city: 'some_city',
  871. country: 'some_country',
  872. state: 'some_state',
  873. zip: 'some_zip',
  874. },
  875. ITMCampaign: 'itm-campaign-value',
  876. ITMContent: 'itm-content-value',
  877. }),
  878. }
  879. })
  880. afterEach(function (ctx) {
  881. ctx.apiRequest.restore()
  882. ctx._parseAccountXml.restore()
  883. ctx._parseBillingInfoXml.restore()
  884. ctx._parseSubscriptionXml.restore()
  885. })
  886. describe('_paypal.checkAccountExists', function () {
  887. beforeEach(function (ctx) {
  888. ctx.call = () => {
  889. return ctx.RecurlyWrapper.promises._paypal.checkAccountExists(
  890. ctx.cache
  891. )
  892. }
  893. })
  894. describe('when the account exists', function () {
  895. beforeEach(function (ctx) {
  896. const resultXml =
  897. '<account><account_code>abc</account_code></account>'
  898. ctx.apiRequest.resolves({
  899. response: { status: 200 },
  900. body: resultXml,
  901. })
  902. })
  903. it('should not produce an error', async function (ctx) {
  904. await expect(ctx.call()).to.be.fulfilled
  905. })
  906. it('should call apiRequest', async function (ctx) {
  907. await ctx.call()
  908. ctx.apiRequest.callCount.should.equal(1)
  909. })
  910. it('should call _parseAccountXml', async function (ctx) {
  911. await ctx.call()
  912. ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(1)
  913. })
  914. it('should add the account to the cumulative result', async function (ctx) {
  915. const result = await ctx.call()
  916. expect(result.account).to.not.equal(null)
  917. expect(result.account).to.not.equal(undefined)
  918. expect(result.account).to.deep.equal({
  919. account_code: 'abc',
  920. })
  921. })
  922. it('should set userExists to true', async function (ctx) {
  923. const result = await ctx.call()
  924. expect(result.userExists).to.equal(true)
  925. })
  926. })
  927. describe('when the account does not exist', function () {
  928. beforeEach(function (ctx) {
  929. ctx.apiRequest.resolves({
  930. response: { status: 404 },
  931. body: '',
  932. })
  933. })
  934. it('should not produce an error', async function (ctx) {
  935. await expect(ctx.call()).to.be.fulfilled
  936. })
  937. it('should call apiRequest', async function (ctx) {
  938. await ctx.call()
  939. ctx.apiRequest.callCount.should.equal(1)
  940. ctx.apiRequest.firstCall.args[0].method.should.equal('GET')
  941. })
  942. it('should not call _parseAccountXml', async function (ctx) {
  943. await ctx.call()
  944. ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(0)
  945. })
  946. it('should not add the account to result', async function (ctx) {
  947. const result = await ctx.call()
  948. expect(result.account).to.equal(undefined)
  949. })
  950. it('should set userExists to false', async function (ctx) {
  951. const result = await ctx.call()
  952. expect(result.userExists).to.equal(false)
  953. })
  954. })
  955. describe('when apiRequest produces an error', function () {
  956. beforeEach(function (ctx) {
  957. ctx.apiRequest.rejects(new Error('woops'))
  958. })
  959. it('should produce an error', async function (ctx) {
  960. await expect(ctx.call()).to.be.rejected
  961. })
  962. })
  963. })
  964. describe('_paypal.createAccount', function () {
  965. beforeEach(function (ctx) {
  966. ctx.call = () => {
  967. return ctx.RecurlyWrapper.promises._paypal.createAccount(ctx.cache)
  968. }
  969. })
  970. describe('when address is missing from subscriptionDetails', function () {
  971. beforeEach(function (ctx) {
  972. ctx.cache.subscriptionDetails.address = null
  973. })
  974. it('should produce an error', async function (ctx) {
  975. await expect(ctx.call()).to.be.rejected
  976. })
  977. })
  978. describe('when country is missing from address', function () {
  979. beforeEach(function (ctx) {
  980. ctx.cache.subscriptionDetails.address = {}
  981. })
  982. it('should produce an error', async function (ctx) {
  983. await expect(ctx.call()).to.be.rejectedWith(Errors.InvalidError)
  984. })
  985. })
  986. describe('when account already exists', function () {
  987. beforeEach(function (ctx) {
  988. ctx.cache.userExists = true
  989. ctx.cache.account = { account_code: 'abc' }
  990. })
  991. it('should not produce an error', async function (ctx) {
  992. await expect(ctx.call()).to.be.fulfilled
  993. })
  994. it('should produce cache object', async function (ctx) {
  995. const result = await ctx.call()
  996. expect(result).to.deep.equal(ctx.cache)
  997. expect(result.account).to.deep.equal({
  998. account_code: 'abc',
  999. })
  1000. })
  1001. it('should not call apiRequest', async function (ctx) {
  1002. await expect(ctx.call()).to.be.fulfilled
  1003. ctx.apiRequest.callCount.should.equal(0)
  1004. })
  1005. it('should not call _parseAccountXml', async function (ctx) {
  1006. await ctx.call()
  1007. ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(0)
  1008. })
  1009. })
  1010. describe('when account does not exist', function () {
  1011. beforeEach(function (ctx) {
  1012. ctx.cache.userExists = false
  1013. const resultXml =
  1014. '<account><account_code>abc</account_code></account>'
  1015. ctx.apiRequest.resolves({
  1016. response: { status: 200 },
  1017. body: resultXml,
  1018. })
  1019. })
  1020. it('sends correct XML', async function (ctx) {
  1021. await ctx.call()
  1022. const { body } = ctx.apiRequest.lastCall.args[0]
  1023. expect(body).to.equal(`\
  1024. <account>
  1025. <account_code>some_id</account_code>
  1026. <email>foo@bar.com</email>
  1027. <first_name>Foo</first_name>
  1028. <last_name>Bar</last_name>
  1029. <address>
  1030. <address1>addr_one</address1>
  1031. <address2>addr_two</address2>
  1032. <city>some_city</city>
  1033. <state>some_state</state>
  1034. <zip>some_zip</zip>
  1035. <country>some_country</country>
  1036. </address>
  1037. </account>\
  1038. `)
  1039. })
  1040. it('should not produce an error', async function (ctx) {
  1041. await expect(ctx.call()).to.be.fulfilled
  1042. })
  1043. it('should call apiRequest', async function (ctx) {
  1044. await ctx.call()
  1045. ctx.apiRequest.callCount.should.equal(1)
  1046. ctx.apiRequest.firstCall.args[0].method.should.equal('POST')
  1047. })
  1048. it('should call _parseAccountXml', async function (ctx) {
  1049. await ctx.call()
  1050. ctx.RecurlyWrapper.promises._parseAccountXml.callCount.should.equal(1)
  1051. })
  1052. describe('when apiRequest produces an error', function () {
  1053. beforeEach(function (ctx) {
  1054. ctx.apiRequest.rejects(new Error('woops'))
  1055. })
  1056. it('should produce an error', async function (ctx) {
  1057. await expect(ctx.call()).to.be.rejectedWith('woops')
  1058. })
  1059. })
  1060. })
  1061. })
  1062. describe('_paypal.createBillingInfo', function () {
  1063. beforeEach(function (ctx) {
  1064. ctx.cache.account = { account_code: 'abc' }
  1065. ctx.call = () => {
  1066. return ctx.RecurlyWrapper.promises._paypal.createBillingInfo(
  1067. ctx.cache
  1068. )
  1069. }
  1070. })
  1071. describe('when account_code is missing from cache', function () {
  1072. beforeEach(function (ctx) {
  1073. ctx.cache.account.account_code = null
  1074. })
  1075. it('should produce an error', async function (ctx) {
  1076. await expect(ctx.call()).to.be.rejected
  1077. })
  1078. })
  1079. describe('when all goes well', function () {
  1080. beforeEach(function (ctx) {
  1081. const resultXml = '<billing_info><a>1</a></billing_info>'
  1082. ctx.apiRequest.resolves({
  1083. response: { status: 200 },
  1084. body: resultXml,
  1085. })
  1086. })
  1087. it('sends correct XML', async function (ctx) {
  1088. await ctx.call()
  1089. const { body } = ctx.apiRequest.lastCall.args[0]
  1090. expect(body).to.equal(`\
  1091. <billing_info>
  1092. <token_id>a-token-id</token_id>
  1093. </billing_info>\
  1094. `)
  1095. })
  1096. it('should not produce an error', async function (ctx) {
  1097. await expect(ctx.call()).to.be.fulfilled
  1098. })
  1099. it('should call apiRequest', async function (ctx) {
  1100. await ctx.call()
  1101. ctx.apiRequest.callCount.should.equal(1)
  1102. ctx.apiRequest.firstCall.args[0].method.should.equal('POST')
  1103. })
  1104. it('should call _parseBillingInfoXml', async function (ctx) {
  1105. await ctx.call()
  1106. ctx.RecurlyWrapper.promises._parseBillingInfoXml.callCount.should.equal(
  1107. 1
  1108. )
  1109. })
  1110. it('should set billingInfo on cache', async function (ctx) {
  1111. const result = await ctx.call()
  1112. expect(result.billingInfo).to.deep.equal({
  1113. a: '1',
  1114. })
  1115. })
  1116. })
  1117. describe('when apiRequest produces an error', function () {
  1118. beforeEach(function (ctx) {
  1119. ctx.apiRequest.resolves(new Error('woops'))
  1120. })
  1121. it('should produce an error', async function (ctx) {
  1122. await expect(ctx.call()).to.be.rejected
  1123. })
  1124. })
  1125. })
  1126. describe('_paypal.setAddressAndCompanyBillingInfo', function () {
  1127. beforeEach(function (ctx) {
  1128. ctx.cache.account = { account_code: 'abc' }
  1129. ctx.cache.billingInfo = {}
  1130. ctx.call = () => {
  1131. return ctx.RecurlyWrapper.promises._paypal.setAddressAndCompanyBillingInfo(
  1132. ctx.cache
  1133. )
  1134. }
  1135. })
  1136. describe('when account_code is missing from cache', function () {
  1137. beforeEach(function (ctx) {
  1138. ctx.cache.account.account_code = null
  1139. })
  1140. it('should produce an error', async function (ctx) {
  1141. await expect(ctx.call()).to.be.rejected
  1142. })
  1143. })
  1144. describe('when country is missing', function () {
  1145. beforeEach(function (ctx) {
  1146. ctx.cache.subscriptionDetails.address = { country: '' }
  1147. })
  1148. it('should produce an error', async function (ctx) {
  1149. await expect(ctx.call()).to.be.rejectedWith(Errors.InvalidError)
  1150. })
  1151. })
  1152. describe('when all goes well', function () {
  1153. beforeEach(function (ctx) {
  1154. const resultXml = '<billing_info><city>London</city></billing_info>'
  1155. ctx.apiRequest.resolves({
  1156. response: { status: 200 },
  1157. body: resultXml,
  1158. })
  1159. })
  1160. it('sends correct XML', async function (ctx) {
  1161. await ctx.call()
  1162. const { body } = ctx.apiRequest.lastCall.args[0]
  1163. expect(body).to.equal(`\
  1164. <billing_info>
  1165. <address1>addr_one</address1>
  1166. <address2>addr_two</address2>
  1167. <city>some_city</city>
  1168. <state>some_state</state>
  1169. <zip>some_zip</zip>
  1170. <country>some_country</country>
  1171. </billing_info>\
  1172. `)
  1173. })
  1174. it('should not produce an error', async function (ctx) {
  1175. await expect(ctx.call()).to.be.fulfilled
  1176. })
  1177. it('should call apiRequest', async function (ctx) {
  1178. await ctx.call()
  1179. ctx.apiRequest.callCount.should.equal(1)
  1180. ctx.apiRequest.firstCall.args[0].method.should.equal('PUT')
  1181. })
  1182. it('should call _parseBillingInfoXml', async function (ctx) {
  1183. await ctx.call()
  1184. ctx.RecurlyWrapper.promises._parseBillingInfoXml.callCount.should.equal(
  1185. 1
  1186. )
  1187. })
  1188. it('should set billingInfo on cache', async function (ctx) {
  1189. const result = await ctx.call()
  1190. expect(result.billingInfo).to.deep.equal({
  1191. city: 'London',
  1192. })
  1193. })
  1194. })
  1195. describe('when apiRequest produces an error', function () {
  1196. beforeEach(function (ctx) {
  1197. ctx.apiRequest.rejects(new Error('woops'))
  1198. })
  1199. it('should produce an error', async function (ctx) {
  1200. await expect(ctx.call()).to.be.rejected
  1201. })
  1202. })
  1203. })
  1204. describe('_paypal.createSubscription', function () {
  1205. beforeEach(function (ctx) {
  1206. ctx.cache.account = { account_code: 'abc' }
  1207. ctx.cache.billingInfo = {}
  1208. ctx.call = () =>
  1209. ctx.RecurlyWrapper.promises._paypal.createSubscription(ctx.cache)
  1210. })
  1211. describe('when all goes well', function () {
  1212. beforeEach(function (ctx) {
  1213. const resultXml = '<subscription><a>1</a></subscription>'
  1214. ctx.apiRequest.resolves({
  1215. response: { status: 200 },
  1216. body: resultXml,
  1217. })
  1218. })
  1219. it('sends correct XML', async function (ctx) {
  1220. await ctx.call()
  1221. const { body } = ctx.apiRequest.lastCall.args[0]
  1222. expect(body).to.equal(`\
  1223. <subscription>
  1224. <plan_code>some_plan_code</plan_code>
  1225. <currency>EUR</currency>
  1226. <coupon_code/>
  1227. <account>
  1228. <account_code>some_id</account_code>
  1229. </account>
  1230. <custom_fields>
  1231. <custom_field>
  1232. <name>itm_campaign</name>
  1233. <value>itm-campaign-value</value>
  1234. </custom_field>
  1235. <custom_field>
  1236. <name>itm_content</name>
  1237. <value>itm-content-value</value>
  1238. </custom_field>
  1239. </custom_fields>
  1240. </subscription>\
  1241. `)
  1242. })
  1243. it('should not produce an error', async function (ctx) {
  1244. await expect(ctx.call()).to.be.fulfilled
  1245. })
  1246. it('should call apiRequest', async function (ctx) {
  1247. await ctx.call()
  1248. ctx.apiRequest.callCount.should.equal(1)
  1249. ctx.apiRequest.firstCall.args[0].method.should.equal('POST')
  1250. })
  1251. it('should call _parseSubscriptionXml', async function (ctx) {
  1252. await ctx.call()
  1253. ctx.RecurlyWrapper.promises._parseSubscriptionXml.callCount.should.equal(
  1254. 1
  1255. )
  1256. })
  1257. it('should set subscription on cache', async function (ctx) {
  1258. const result = await ctx.call()
  1259. expect(result.subscription).to.deep.equal({
  1260. a: '1',
  1261. })
  1262. })
  1263. })
  1264. describe('when apiRequest produces an error', function () {
  1265. beforeEach(function (ctx) {
  1266. ctx.apiRequest.rejects(new Error('woops'))
  1267. })
  1268. it('should produce an error', async function (ctx) {
  1269. await expect(ctx.call()).to.be.rejected
  1270. })
  1271. })
  1272. })
  1273. })
  1274. describe('listAccountActiveSubscriptions', function () {
  1275. beforeEach(function (ctx) {
  1276. ctx.user_id = 'mock-user-id'
  1277. ctx.response = { mock: 'response' }
  1278. ctx.body = '<mock body/>'
  1279. ctx.RecurlyWrapper.promises.apiRequest = sinon.stub().resolves({
  1280. response: ctx.response,
  1281. body: ctx.body,
  1282. })
  1283. ctx.subscriptions = ['mock', 'subscriptions']
  1284. ctx.RecurlyWrapper.promises._parseSubscriptionsXml = sinon
  1285. .stub()
  1286. .resolves(ctx.subscriptions)
  1287. })
  1288. describe('with an account', function () {
  1289. beforeEach(async function (ctx) {
  1290. ctx.result =
  1291. await ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions(
  1292. ctx.user_id
  1293. )
  1294. })
  1295. it('should send a request to Recurly', async function (ctx) {
  1296. ctx.RecurlyWrapper.promises.apiRequest
  1297. .calledWith({
  1298. url: `accounts/${ctx.user_id}/subscriptions`,
  1299. qs: {
  1300. state: 'active',
  1301. },
  1302. expect404: true,
  1303. })
  1304. .should.equal(true)
  1305. })
  1306. it('should return the subscriptions', async function (ctx) {
  1307. expect(ctx.result).to.deep.equal(ctx.subscriptions)
  1308. })
  1309. })
  1310. describe('without an account', function () {
  1311. beforeEach(async function (ctx) {
  1312. ctx.response.status = 404
  1313. ctx.accountActiveSubscriptions =
  1314. await ctx.RecurlyWrapper.promises.listAccountActiveSubscriptions(
  1315. ctx.user_id
  1316. )
  1317. })
  1318. it('should return an empty array of subscriptions', function (ctx) {
  1319. expect(ctx.accountActiveSubscriptions).to.deep.equal([])
  1320. })
  1321. })
  1322. })
  1323. describe('extendTrial', function () {
  1324. beforeEach(function (ctx) {
  1325. ctx.subscriptionId = 'subscription-id-123'
  1326. })
  1327. afterEach(function (ctx) {
  1328. ctx.RecurlyWrapper.promises.apiRequest.restore()
  1329. tk.reset()
  1330. })
  1331. describe('with default parameters (7 days)', function () {
  1332. beforeEach(async function (ctx) {
  1333. tk.freeze(new Date('2025-01-25T10:30:00Z'))
  1334. ctx.apiRequest = sinon
  1335. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  1336. .resolves()
  1337. await ctx.RecurlyWrapper.promises.extendTrial(ctx.subscriptionId)
  1338. })
  1339. it('should extend trial by 7 days from current time', function (ctx) {
  1340. const options = ctx.apiRequest.lastCall.args[0]
  1341. expect(options.qs.next_bill_date).to.deep.equal(
  1342. new Date('2025-02-01T10:30:00Z')
  1343. )
  1344. })
  1345. })
  1346. describe('extending trial across year boundary', function () {
  1347. beforeEach(async function (ctx) {
  1348. // Trial ends on December 28, 2025, extend by 14 days to cross into 2026
  1349. ctx.trialEndsAt = new Date('2025-12-28T12:00:00Z')
  1350. ctx.daysUntilExpire = 14
  1351. ctx.apiRequest = sinon
  1352. .stub(ctx.RecurlyWrapper.promises, 'apiRequest')
  1353. .resolves()
  1354. await ctx.RecurlyWrapper.promises.extendTrial(
  1355. ctx.subscriptionId,
  1356. ctx.trialEndsAt,
  1357. ctx.daysUntilExpire
  1358. )
  1359. })
  1360. it('should correctly calculate date across year boundary', function (ctx) {
  1361. const options = ctx.apiRequest.lastCall.args[0]
  1362. expect(options.qs.next_bill_date).to.deep.equal(
  1363. new Date('2026-01-11T12:00:00Z')
  1364. )
  1365. })
  1366. })
  1367. })
  1368. })