CollectPayPalPastDueInvoiceTest.mjs 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. import sinon from 'sinon'
  2. import chai, { expect } from 'chai'
  3. import chaiAsPromised from 'chai-as-promised'
  4. import sinonChai from 'sinon-chai'
  5. import { collectPastDueInvoices } from '../../../scripts/recurly/collect_paypal_past_due_invoice.mjs'
  6. import RecurlyWrapper from '../../../app/src/Features/Subscription/RecurlyWrapper.js'
  7. import OError from '@overleaf/o-error'
  8. chai.use(chaiAsPromised)
  9. chai.use(sinonChai)
  10. // from https://recurly.com/developers/api-v2/v2.21/#operation/listInvoices
  11. const invoicesXml = invoiceIds => `
  12. <invoices type="array">
  13. ${invoiceIds
  14. .map(
  15. invoiceId => `
  16. <invoice href="https://your-subdomain.recurly.com/v2/invoices/${invoiceId}">
  17. <account href="https://your-subdomain.recurly.com/v2/accounts/${invoiceId}"/>
  18. <subscriptions href="https://your-subdomain.recurly.com/v2/invoices/${invoiceId}/subscriptions"/>
  19. <address>
  20. <address1></address1>
  21. <address2></address2>
  22. <city></city>
  23. <state></state>
  24. <zip></zip>
  25. <country></country>
  26. <phone></phone>
  27. </address>
  28. <shipping_address>
  29. <name>Lon Doner</name>
  30. <address1>221B Baker St.</address1>
  31. <address2></address2>
  32. <city>London</city>
  33. <state></state>
  34. <zip>W1K 6AH</zip>
  35. <country>GB</country>
  36. <phone></phone>
  37. </shipping_address>
  38. <uuid>421f7b7d414e4c6792938e7c49d552e9</uuid>
  39. <state>paid</state>
  40. <invoice_number_prefix></invoice_number_prefix> <!-- Only populated for VAT Country Invoice Sequencing. Shows a country code. -->
  41. <invoice_number type="integer">${invoiceId}</invoice_number>
  42. <po_number nil="nil"></po_number>
  43. <vat_number nil="nil"></vat_number>
  44. <subtotal_in_cents type="integer">2000</subtotal_in_cents>
  45. <discount_in_cents type="integer">0</discount_in_cents>
  46. <due_on type="datetime">2018-01-30T21:11:50Z</due_on>
  47. <balance_in_cents type="integer">0</balance_in_cents>
  48. <type>charge</type>
  49. <origin>purchase</origin>
  50. <credit_invoices href="https://your-subdomain.recurly.com/v2/invoices/1325/credit_invoices"/>
  51. <refundable_total_in_cents type="integer">2000</refundable_total_in_cents>
  52. <credit_payments type="array">
  53. </credit_payments>
  54. <tax_in_cents type="integer">0</tax_in_cents>
  55. <total_in_cents type="integer">1200</total_in_cents>
  56. <currency>USD</currency>
  57. <created_at type="datetime">2016-06-25T12:00:00Z</created_at>
  58. <closed_at nil="nil"></closed_at>
  59. <terms_and_conditions></terms_and_conditions>
  60. <customer_notes></customer_notes>
  61. <vat_reverse_charge_notes></vat_reverse_charge_notes>
  62. <tax_type>usst</tax_type>
  63. <tax_region>CA</tax_region>
  64. <tax_rate type="float">0</tax_rate>
  65. <net_terms type="integer">0</net_terms>
  66. <collection_method>automatic</collection_method>
  67. <redemptions href="https://your-subdomain.recurly.com/v2/invoices/e3f0a9e084a2468480d00ee61b090d4d/redemptions"/>
  68. <line_items type="array">
  69. <adjustment href="https://your-subdomain.recurly.com/v2/adjustments/05a4bbdeda2a47348185270021e6087b">
  70. </adjustment>
  71. </line_items>
  72. <transactions type="array">
  73. </transactions>
  74. </invoice>`
  75. )
  76. .join('')}
  77. </invoices>
  78. `
  79. // from https://recurly.com/developers/api-v2/v2.21/#operation/lookupAccountsBillingInfo
  80. const billingInfoXml = `
  81. <billing_info href="https://your-subdomain.recurly.com/v2/accounts/1/billing_info" type="credit_card">
  82. <paypal_billing_agreement_id>PAYPAL_BILLING_AGREEMENT_ID</paypal_billing_agreement_id>
  83. <account href="https://your-subdomain.recurly.com/v2/accounts/1"/>
  84. <first_name>Verena</first_name>
  85. <last_name>Example</last_name>
  86. <company nil="nil"/>
  87. <address1>123 Main St.</address1>
  88. <address2 nil="nil"/>
  89. <city>San Francisco</city>
  90. <state>CA</state>
  91. <zip>94105</zip>
  92. <country>US</country>
  93. <phone nil="nil"/>
  94. <vat_number nil="nil"/>
  95. <ip_address>127.0.0.1</ip_address>
  96. <ip_address_country nil="nil"/>
  97. <card_type>Visa</card_type>
  98. <year type="integer">2019</year>
  99. <month type="integer">11</month>
  100. <first_six>411111</first_six>
  101. <last_four>1111</last_four>
  102. <updated_at type="datetime">2017-02-17T15:38:53Z</updated_at>
  103. </billing_info>
  104. `
  105. // from https://recurly.com/developers/api-v2/v2.21/#operation/collectAnInvoice
  106. const invoiceCollectXml = `
  107. <invoice href="https://your-subdomain.recurly.com/v2/invoices/1000">
  108. <account href="https://your-subdomain.recurly.com/v2/accounts/1"/>
  109. <subscriptions href="https://your-subdomain.recurly.com/v2/invoices/1000/subscriptions"/>
  110. <address>
  111. <address1>123 Main St.</address1>
  112. <address2 nil="nil"/>
  113. <city>San Francisco</city>
  114. <state>CA</state>
  115. <zip>94105</zip>
  116. <country>US</country>
  117. <phone nil="nil"/>
  118. </address>
  119. <uuid>374a37924f83c733b9c9814e9580496a</uuid>
  120. <state>pending</state>
  121. <invoice_number_prefix/>
  122. <invoice_number type="integer">1000</invoice_number>
  123. <po_number nil="nil"/>
  124. <vat_number nil="nil"/>
  125. <subtotal_in_cents type="integer">5000</subtotal_in_cents>
  126. <tax_in_cents type="integer">438</tax_in_cents>
  127. <total_in_cents type="integer">5438</total_in_cents>
  128. <currency>USD</currency>
  129. <created_at type="datetime">2016-07-11T19:25:57Z</created_at>
  130. <updated_at type="datetime">2016-07-11T19:25:57Z</updated_at>
  131. <closed_at nil="nil"/>
  132. <terms_and_conditions nil="nil"/>
  133. <customer_notes nil="nil"/>
  134. <tax_type>usst</tax_type>
  135. <tax_region>CA</tax_region>
  136. <tax_rate type="float">0.0875</tax_rate>
  137. <net_terms type="integer">0</net_terms>
  138. <collection_method>automatic</collection_method>
  139. <line_items type="array">
  140. <adjustment href="https://your-subdomain.recurly.com/v2/adjustments/374a2729397882fafbc82041a0a4dd0d" type="charge">
  141. <!-- Detail. -->
  142. </adjustment>
  143. </line_items>
  144. <transactions type="array">
  145. </transactions>
  146. <a name="mark_successful" href="https://your-subdomain.recurly.com/v2/invoices/1000/mark_successful" method="put"/>
  147. <a name="mark_failed" href="https://your-subdomain.recurly.com/v2/invoices/1000/mark_failed" method="put"/>
  148. </invoice>
  149. `
  150. const ITEMS_PER_PAGE = 3
  151. const getInvoicePage = fullInvoicesIds => queryOptions => {
  152. const cursor = queryOptions.qs.cursor
  153. const startEnd = cursor?.split(':').map(Number) || []
  154. const start = startEnd[0] || 0
  155. const end = startEnd[1] || ITEMS_PER_PAGE
  156. const body = invoicesXml(fullInvoicesIds.slice(start, end))
  157. const hasMore = end < fullInvoicesIds.length
  158. const nextPageCursor = hasMore ? `${end}%3A${end + ITEMS_PER_PAGE}&v=2` : null
  159. const response = {
  160. status: 200,
  161. headers: {
  162. link: hasMore
  163. ? `https://fakerecurly.com/v2/invoices?cursor=${nextPageCursor}`
  164. : undefined,
  165. },
  166. }
  167. return { response, body }
  168. }
  169. describe('CollectPayPalPastDueInvoice', function () {
  170. let apiRequestStub
  171. const fakeApiRequests = invoiceIds => {
  172. apiRequestStub = sinon.stub(RecurlyWrapper.promises, 'apiRequest')
  173. apiRequestStub.callsFake(options => {
  174. if (options.url === 'invoices') {
  175. return getInvoicePage(invoiceIds)(options)
  176. }
  177. if (/accounts\/(\d+)\/billing_info/.test(options.url)) {
  178. return {
  179. response: { status: 200, headers: {} },
  180. body: billingInfoXml,
  181. }
  182. }
  183. if (/invoices\/(\d+)\/collect/.test(options.url)) {
  184. const invoiceId = options.url.match(/invoices\/(\d+)\/collect/)[1]
  185. if (invoiceId < 400) {
  186. return {
  187. response: { status: 200, headers: {} },
  188. body: invoiceCollectXml,
  189. }
  190. }
  191. throw new OError(`Recurly API returned with status code: 404`, {
  192. statusCode: 404,
  193. })
  194. }
  195. })
  196. }
  197. afterEach(function () {
  198. apiRequestStub?.restore()
  199. })
  200. it('collects one valid invoice', async function () {
  201. fakeApiRequests([200])
  202. const r = await collectPastDueInvoices()
  203. expect(r).to.eql({
  204. INVOICES_COLLECTED: [200],
  205. INVOICES_COLLECTED_SUCCESS: [200],
  206. USERS_COLLECTED: ['200'],
  207. })
  208. })
  209. it('collects several pages', async function () {
  210. // 10 invoices, from 200 to 209
  211. fakeApiRequests([...Array(10).keys()].map(i => i + 200))
  212. const r = await collectPastDueInvoices()
  213. expect(r).to.eql({
  214. INVOICES_COLLECTED: [200, 201, 202, 203, 204, 205, 206, 207, 208, 209],
  215. INVOICES_COLLECTED_SUCCESS: [
  216. 200, 201, 202, 203, 204, 205, 206, 207, 208, 209,
  217. ],
  218. USERS_COLLECTED: [
  219. '200',
  220. '201',
  221. '202',
  222. '203',
  223. '204',
  224. '205',
  225. '206',
  226. '207',
  227. '208',
  228. '209',
  229. ],
  230. })
  231. // 4 calls to get the invoices
  232. // 10 calls to get the billing info
  233. // 10 calls to collect the invoices
  234. expect(apiRequestStub.callCount).to.eql(24)
  235. })
  236. it("resolves when no invoices are processed so we don't fail in staging", async function () {
  237. fakeApiRequests([404])
  238. const r = await collectPastDueInvoices()
  239. expect(r).to.eql({
  240. INVOICES_COLLECTED: [404],
  241. INVOICES_COLLECTED_SUCCESS: [],
  242. USERS_COLLECTED: ['404'],
  243. })
  244. })
  245. it('doesnt reject when there are no invoices', async function () {
  246. fakeApiRequests([])
  247. const r = await collectPastDueInvoices()
  248. expect(r).to.eql({
  249. INVOICES_COLLECTED: [],
  250. INVOICES_COLLECTED_SUCCESS: [],
  251. USERS_COLLECTED: [],
  252. })
  253. })
  254. it("resolves when collection is partially successful so we don't fail in prod", async function () {
  255. fakeApiRequests([200, 404])
  256. const r = await collectPastDueInvoices()
  257. expect(r).to.eql({
  258. INVOICES_COLLECTED: [200, 404],
  259. INVOICES_COLLECTED_SUCCESS: [200],
  260. USERS_COLLECTED: ['200', '404'],
  261. })
  262. })
  263. })