diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 6714da86f1..830b3a047e 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -583,7 +583,7 @@ const RecurlyWrapper = { } return RecurlyWrapper._parseXml(body, function (err, data) { if (err) { - logger.warn({ err }, 'could not get accoutns') + logger.warn({ err }, 'could not get accounts') return callback(err) } const items = data[resource] diff --git a/services/web/scripts/recurly/collect_paypal_past_due_invoice.js b/services/web/scripts/recurly/collect_paypal_past_due_invoice.js index 8ede1d5278..89a5702bb4 100644 --- a/services/web/scripts/recurly/collect_paypal_past_due_invoice.js +++ b/services/web/scripts/recurly/collect_paypal_past_due_invoice.js @@ -1,102 +1,136 @@ const RecurlyWrapper = require('../../app/src/Features/Subscription/RecurlyWrapper') const async = require('async') const minimist = require('minimist') +const logger = require('@overleaf/logger') -const slowCallback = (callback, error, data) => - setTimeout(() => callback(error, data), 80) +const slowCallback = + require.main === module + ? (callback, error, data) => setTimeout(() => callback(error, data), 80) + : (callback, error, data) => callback(error, data) +// NOTE: Errors are not propagated to the caller const handleAPIError = (source, id, error, callback) => { - console.warn(`Errors in ${source} with id=${id}`, error) + logger.warn(`Errors in ${source} with id=${id}`, error) if (typeof error === 'string' && error.match(/429$/)) { return setTimeout(callback, 1000 * 60 * 5) } slowCallback(callback) } -const attemptInvoiceCollection = (invoice, callback) => { - isAccountUsingPaypal(invoice, (error, isPaypal) => { - if (error || !isPaypal) { - return callback(error) - } - const accountId = invoice.account.url.match(/accounts\/(.*)/)[1] - if (USERS_COLLECTED.indexOf(accountId) > -1) { - console.warn(`Skipping duplicate user ${accountId}`) - return callback() - } - INVOICES_COLLECTED.push(invoice.invoice_number) - USERS_COLLECTED.push(accountId) - if (DRY_RUN) { - return callback() - } - RecurlyWrapper.attemptInvoiceCollection( - invoice.invoice_number, - (error, response) => { - if (error) { - return handleAPIError( - 'attemptInvoiceCollection', - invoice.invoice_number, - error, - callback - ) - } - INVOICES_COLLECTED_SUCCESS.push(invoice.invoice_number) - slowCallback(callback, null) - } - ) - }) -} - -const isAccountUsingPaypal = (invoice, callback) => { - const accountId = invoice.account.url.match(/accounts\/(.*)/)[1] - RecurlyWrapper.getBillingInfo(accountId, (error, response) => { - if (error) { - return handleAPIError('billing info', accountId, error, callback) - } - if (response.billing_info.paypal_billing_agreement_id) { - return slowCallback(callback, null, true) - } - slowCallback(callback, null, false) - }) -} - -const attemptInvoicesCollection = callback => { - RecurlyWrapper.getPaginatedEndpoint( - 'invoices', - { state: 'past_due' }, - (error, invoices) => { - console.log('invoices', invoices.length) - if (error) { +/** + * @returns {Promise<{ + * INVOICES_COLLECTED: string[], + * INVOICES_COLLECTED_SUCCESS: string[], + * USERS_COLLECTED: string[], + * }>} + */ +const main = async () => { + const attemptInvoiceCollection = (invoice, callback) => { + isAccountUsingPaypal(invoice, (error, isPaypal) => { + if (error || !isPaypal) { return callback(error) } - async.eachSeries(invoices, attemptInvoiceCollection, callback) - } - ) + const accountId = invoice.account.url.match(/accounts\/(.*)/)[1] + if (USERS_COLLECTED.indexOf(accountId) > -1) { + logger.warn(`Skipping duplicate user ${accountId}`) + return callback() + } + INVOICES_COLLECTED.push(invoice.invoice_number) + USERS_COLLECTED.push(accountId) + if (DRY_RUN) { + return callback() + } + RecurlyWrapper.attemptInvoiceCollection( + invoice.invoice_number, + (error, response) => { + if (error) { + return handleAPIError( + 'attemptInvoiceCollection', + invoice.invoice_number, + error, + callback + ) + } + INVOICES_COLLECTED_SUCCESS.push(invoice.invoice_number) + slowCallback(callback, null) + } + ) + }) + } + + const isAccountUsingPaypal = (invoice, callback) => { + const accountId = invoice.account.url.match(/accounts\/(.*)/)[1] + RecurlyWrapper.getBillingInfo(accountId, (error, response) => { + if (error) { + return handleAPIError('billing info', accountId, error, callback) + } + if (response.billing_info.paypal_billing_agreement_id) { + return slowCallback(callback, null, true) + } + slowCallback(callback, null, false) + }) + } + + const attemptInvoicesCollection = callback => { + RecurlyWrapper.getPaginatedEndpoint( + 'invoices', + { state: 'past_due' }, + (error, invoices) => { + logger.info('invoices', invoices.length) + if (error) { + return callback(error) + } + async.eachSeries(invoices, attemptInvoiceCollection, callback) + } + ) + } + const argv = minimist(process.argv.slice(2)) + const DRY_RUN = argv.n !== undefined + const INVOICES_COLLECTED = [] + const INVOICES_COLLECTED_SUCCESS = [] + const USERS_COLLECTED = [] + + return new Promise(resolve => { + attemptInvoicesCollection(error => { + logger.info( + `DONE (DRY_RUN=${DRY_RUN}). ${INVOICES_COLLECTED.length} invoices collection attempts for ${USERS_COLLECTED.length} users. ${INVOICES_COLLECTED_SUCCESS.length} successful collections` + ) + console.dir( + { + INVOICES_COLLECTED, + INVOICES_COLLECTED_SUCCESS, + USERS_COLLECTED, + }, + { maxArrayLength: null } + ) + + if (error) { + throw error + } + + if (INVOICES_COLLECTED_SUCCESS.length === 0) { + throw new Error('No invoices collected') + } + + resolve({ + INVOICES_COLLECTED, + INVOICES_COLLECTED_SUCCESS, + USERS_COLLECTED, + }) + }) + }) } -const argv = minimist(process.argv.slice(2)) -const DRY_RUN = argv.n !== undefined -const INVOICES_COLLECTED = [] -const INVOICES_COLLECTED_SUCCESS = [] -const USERS_COLLECTED = [] -attemptInvoicesCollection(error => { - if (error) { - throw error - } - console.log( - `DONE (DRY_RUN=${DRY_RUN}). ${INVOICES_COLLECTED.length} invoices collection attempts for ${USERS_COLLECTED.length} users. ${INVOICES_COLLECTED_SUCCESS.length} successful collections` - ) - console.dir( - { - INVOICES_COLLECTED, - INVOICES_COLLECTED_SUCCESS, - USERS_COLLECTED, - }, - { maxArrayLength: null } - ) +if (require.main === module) { + main() + .then(() => { + logger.error('Done.') + process.exit(0) + }) + .catch(err => { + logger.error('Error', err) + process.exit(1) + }) +} - if (INVOICES_COLLECTED_SUCCESS === 0) { - process.exit(1) - } else { - process.exit() - } -}) +module.exports = { main } diff --git a/services/web/test/acceptance/src/CollectPayPalPastDueInvoiceTest.js b/services/web/test/acceptance/src/CollectPayPalPastDueInvoiceTest.js new file mode 100644 index 0000000000..57ba6b85ef --- /dev/null +++ b/services/web/test/acceptance/src/CollectPayPalPastDueInvoiceTest.js @@ -0,0 +1,235 @@ +const sinon = require('sinon') +const chai = require('chai') +const { expect } = require('chai') + +chai.use(require('chai-as-promised')) +chai.use(require('sinon-chai')) + +const { + main, +} = require('../../../scripts/recurly/collect_paypal_past_due_invoice') + +const RecurlyWrapper = require('../../../app/src/Features/Subscription/RecurlyWrapper') +const OError = require('@overleaf/o-error') + +// from https://recurly.com/developers/api-v2/v2.21/#operation/listInvoices +const invoicesXml = invoiceIds => ` + + ${invoiceIds + .map( + invoiceId => ` + + + +
+ + + + + + + +
+ + Lon Doner + 221B Baker St. + + London + + W1K 6AH + GB + + + 421f7b7d414e4c6792938e7c49d552e9 + paid + + ${invoiceId} + + + 2000 + 0 + 2018-01-30T21:11:50Z + 0 + charge + purchase + + 2000 + + + 0 + 1200 + USD + 2016-06-25T12:00:00Z + + + + + usst + CA + 0 + 0 + automatic + + + + + + + +
` + ) + .join('')} +
+` + +// from https://recurly.com/developers/api-v2/v2.21/#operation/lookupAccountsBillingInfo +const billingInfoXml = ` + + PAYPAL_BILLING_AGREEMENT_ID + + Verena + Example + + 123 Main St. + + San Francisco + CA + 94105 + US + + + 127.0.0.1 + + Visa + 2019 + 11 + 411111 + 1111 + 2017-02-17T15:38:53Z + +` + +// from https://recurly.com/developers/api-v2/v2.21/#operation/collectAnInvoice +const invoiceCollectXml = ` + + + +
+ 123 Main St. + + San Francisco + CA + 94105 + US + +
+ 374a37924f83c733b9c9814e9580496a + pending + + 1000 + + + 5000 + 438 + 5438 + USD + 2016-07-11T19:25:57Z + 2016-07-11T19:25:57Z + + + + usst + CA + 0.0875 + 0 + automatic + + + + + + + + + +
+` + +// from our logs +const invoiceCollectErrXml2 = ` + + + not_found + Couldn't find BillingInfo with account_code = abcdef87654321 + +` + +describe('CollectPayPalPastDueInvoice', function () { + let apiRequestStub + const fakeApiRequests = invoiceIdsAndReturnCode => { + apiRequestStub = sinon.stub(RecurlyWrapper, 'apiRequest') + apiRequestStub.callsFake((options, callback) => { + switch (options.url) { + case 'invoices': + callback( + null, + { statusCode: 200, headers: {} }, + invoicesXml(invoiceIdsAndReturnCode) + ) + return + case 'accounts/200/billing_info': + case 'accounts/404/billing_info': + callback(null, { statusCode: 200, headers: {} }, billingInfoXml) + return + case 'invoices/200/collect': + callback(null, { statusCode: 200, headers: {} }, invoiceCollectXml) + return + case 'invoices/404/collect': + callback( + new OError(`Recurly API returned with status code: 404`, { + statusCode: 404, + }), + { statusCode: 404, headers: {} }, + invoiceCollectErrXml2 + ) + return + default: + throw new Error(`Unexpected URL: ${options.url}`) + } + }) + } + + afterEach(function () { + apiRequestStub?.restore() + }) + + it('collects one valid invoice', async function () { + fakeApiRequests([200]) + const r = await main() + await expect(r).to.eql({ + INVOICES_COLLECTED: [200], + INVOICES_COLLECTED_SUCCESS: [200], + USERS_COLLECTED: ['200'], + }) + }) + + it('rejects with no invoices are processed because of errors', async function () { + fakeApiRequests([404]) + await expect(main()).to.be.rejectedWith('No invoices collected') + }) + + it('rejects when there are no invoices', async function () { + fakeApiRequests([]) + await expect(main()).to.be.rejectedWith('No invoices collected') + }) + + it('resolves when some invoices are partially successful', async function () { + fakeApiRequests([200, 404]) + const r = await main() + await expect(r).to.eql({ + INVOICES_COLLECTED: [200, 404], + INVOICES_COLLECTED_SUCCESS: [200], + USERS_COLLECTED: ['200', '404'], + }) + }) +})