Merge pull request #2343 from overleaf/ta-invoice-attempt-callback

Collect Past Due Invoices on Paypal Billing Info Updates

GitOrigin-RevId: 6a0d298db8589ae6ba7cb62e4dfd562a1f292db0
This commit is contained in:
Timothée Alby 2019-11-12 15:56:08 +07:00 committed by sharelatex
parent 012bef257d
commit e000fd4615
7 changed files with 284 additions and 29 deletions

View file

@ -529,12 +529,12 @@ module.exports = RecurlyWrapper = {
) )
}, },
getAccounts(queryParams, callback) { getPaginatedEndpoint(resource, queryParams, callback) {
queryParams.per_page = queryParams.per_page || 200 queryParams.per_page = queryParams.per_page || 200
let allAccounts = [] let allItems = []
var getPageOfAccounts = (cursor = null) => { var getPage = (cursor = null) => {
const opts = { const opts = {
url: 'accounts', url: resource,
qs: queryParams qs: queryParams
} }
if (cursor != null) { if (cursor != null) {
@ -549,11 +549,10 @@ module.exports = RecurlyWrapper = {
logger.warn({ err }, 'could not get accoutns') logger.warn({ err }, 'could not get accoutns')
callback(err) callback(err)
} }
allAccounts = allAccounts.concat(data.accounts) const items = data[resource]
allItems = allItems.concat(items)
logger.log( logger.log(
`got another ${data.accounts.length}, total now ${ `got another ${items.length}, total now ${allItems.length}`
allAccounts.length
}`
) )
cursor = __guard__( cursor = __guard__(
response.headers.link != null response.headers.link != null
@ -563,15 +562,15 @@ module.exports = RecurlyWrapper = {
) )
if (cursor != null) { if (cursor != null) {
cursor = decodeURIComponent(cursor) cursor = decodeURIComponent(cursor)
return getPageOfAccounts(cursor) return getPage(cursor)
} else { } else {
return callback(err, allAccounts) return callback(err, allItems)
} }
}) })
}) })
} }
return getPageOfAccounts() return getPage()
}, },
getAccount(accountId, callback) { getAccount(accountId, callback) {
@ -645,6 +644,30 @@ module.exports = RecurlyWrapper = {
) )
}, },
getAccountPastDueInvoices(accountId, callback) {
RecurlyWrapper.apiRequest(
{
url: `accounts/${accountId}/invoices?state=past_due`
},
(error, response, body) => {
if (error) {
return callback(error)
}
RecurlyWrapper._parseInvoicesXml(body, callback)
}
)
},
attemptInvoiceCollection(invoiceId, callback) {
RecurlyWrapper.apiRequest(
{
url: `invoices/${invoiceId}/collect`,
method: 'put'
},
callback
)
},
updateSubscription(subscriptionId, options, callback) { updateSubscription(subscriptionId, options, callback) {
logger.log( logger.log(
{ subscriptionId, options }, { subscriptionId, options },
@ -926,6 +949,10 @@ module.exports = RecurlyWrapper = {
return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'errors', callback) return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'errors', callback)
}, },
_parseInvoicesXml(xml, callback) {
return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'invoices', callback)
},
_parseXmlAndGetAttribute(xml, attribute, callback) { _parseXmlAndGetAttribute(xml, attribute, callback) {
return RecurlyWrapper._parseXml(xml, function(error, data) { return RecurlyWrapper._parseXml(xml, function(error, data) {
if (error != null) { if (error != null) {

View file

@ -356,7 +356,6 @@ module.exports = SubscriptionController = {
recurlyCallback(req, res, next) { recurlyCallback(req, res, next) {
logger.log({ data: req.body }, 'received recurly callback') logger.log({ data: req.body }, 'received recurly callback')
// we only care if a subscription has exipired
const event = Object.keys(req.body)[0] const event = Object.keys(req.body)[0]
const eventData = req.body[event] const eventData = req.body[event]
if ( if (
@ -367,7 +366,7 @@ module.exports = SubscriptionController = {
].includes(event) ].includes(event)
) { ) {
const recurlySubscription = eventData.subscription const recurlySubscription = eventData.subscription
return SubscriptionHandler.recurlyCallback( return SubscriptionHandler.syncSubscription(
recurlySubscription, recurlySubscription,
{ ip: req.ip }, { ip: req.ip },
function(err) { function(err) {
@ -377,6 +376,17 @@ module.exports = SubscriptionController = {
return res.sendStatus(200) return res.sendStatus(200)
} }
) )
} else if (event === 'billing_info_updated_notification') {
const recurlyAccountCode = eventData.account.account_code
return SubscriptionHandler.attemptPaypalInvoiceCollection(
recurlyAccountCode,
function(err) {
if (err) {
return next(err)
}
return res.sendStatus(200)
}
)
} else { } else {
return res.sendStatus(200) return res.sendStatus(200)
} }

View file

@ -230,7 +230,7 @@ const SubscriptionHandler = {
}) })
}, },
recurlyCallback(recurlySubscription, requesterData, callback) { syncSubscription(recurlySubscription, requesterData, callback) {
return RecurlyWrapper.getSubscription( return RecurlyWrapper.getSubscription(
recurlySubscription.uuid, recurlySubscription.uuid,
{ includeAccount: true }, { includeAccount: true },
@ -259,6 +259,38 @@ const SubscriptionHandler = {
) )
}, },
// attempt to collect past due invoice for customer. Only do that when a) the
// customer is using Paypal and b) there is only one past due invoice.
// This is used because Recurly doesn't always attempt collection of paast due
// invoices after Paypal billing info were updated.
attemptPaypalInvoiceCollection(recurlyAccountCode, callback) {
RecurlyWrapper.getBillingInfo(recurlyAccountCode, (error, billingInfo) => {
if (error) {
return callback(error)
}
if (!billingInfo.paypal_billing_agreement_id) {
// this is not a Paypal user
return callback()
}
RecurlyWrapper.getAccountPastDueInvoices(
recurlyAccountCode,
(error, pastDueInvoices) => {
if (error) {
return callback(error)
}
if (pastDueInvoices.length !== 1) {
// no past due invoices, or more than one. Ignore.
return callback()
}
RecurlyWrapper.attemptInvoiceCollection(
pastDueInvoices[0].invoice_number,
callback
)
}
)
})
},
extendTrial(subscription, daysToExend, callback) { extendTrial(subscription, daysToExend, callback) {
return RecurlyWrapper.extendTrial( return RecurlyWrapper.extendTrial(
subscription.recurlySubscription_id, subscription.recurlySubscription_id,

View file

@ -0,0 +1,90 @@
const RecurlyWrapper = require('../../app/src/Features/Subscription/RecurlyWrapper')
const async = require('async')
const minimist = require('minimist')
const slowCallback = (callback, error, data) =>
setTimeout(() => callback(error, data), 80)
const handleAPIError = (source, id, error, callback) => {
console.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
)
}
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) {
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 USERS_COLLECTED = []
attemptInvoicesCollection(error => {
if (error) {
throw error
}
console.log(
`DONE (DRY_RUN=${DRY_RUN}). ${
INVOICES_COLLECTED.length
} invoices collected for ${USERS_COLLECTED.length} users.`
)
console.log({ INVOICES_COLLECTED, USERS_COLLECTED })
process.exit()
})

View file

@ -73,15 +73,19 @@ const printAccountCSV = (account, callback) => {
} }
const printAccountsCSV = callback => { const printAccountsCSV = callback => {
RecurlyWrapper.getAccounts({ state: 'subscriber' }, (error, accounts) => { RecurlyWrapper.getPaginatedEndpoint(
if (error) { 'accounts',
return callback(error) { state: 'subscriber' },
(error, accounts) => {
if (error) {
return callback(error)
}
async.mapSeries(accounts, printAccountCSV, (error, csvData) => {
csvData = csvData.filter(d => !!d)
callback(error, csvData)
})
} }
async.mapSeries(accounts, printAccountCSV, (error, csvData) => { )
csvData = csvData.filter(d => !!d)
callback(error, csvData)
})
})
} }
const csvFields = [ const csvFields = [

View file

@ -60,7 +60,8 @@ describe('SubscriptionController', function() {
updateSubscription: sinon.stub().callsArgWith(3), updateSubscription: sinon.stub().callsArgWith(3),
reactivateSubscription: sinon.stub().callsArgWith(1), reactivateSubscription: sinon.stub().callsArgWith(1),
cancelSubscription: sinon.stub().callsArgWith(1), cancelSubscription: sinon.stub().callsArgWith(1),
recurlyCallback: sinon.stub().yields(), syncSubscription: sinon.stub().yields(),
attemptPaypalInvoiceCollection: sinon.stub().yields(),
startFreeTrial: sinon.stub() startFreeTrial: sinon.stub()
} }
@ -514,7 +515,7 @@ describe('SubscriptionController', function() {
}) })
describe('recurly callback', function() { describe('recurly callback', function() {
describe('with a actionable request', function() { describe('with a sync subscription request', function() {
beforeEach(function(done) { beforeEach(function(done) {
this.req = { this.req = {
body: { body: {
@ -535,7 +536,7 @@ describe('SubscriptionController', function() {
}) })
it('should tell the SubscriptionHandler to process the recurly callback', function(done) { it('should tell the SubscriptionHandler to process the recurly callback', function(done) {
this.SubscriptionHandler.recurlyCallback.called.should.equal(true) this.SubscriptionHandler.syncSubscription.called.should.equal(true)
return done() return done()
}) })
@ -545,6 +546,39 @@ describe('SubscriptionController', function() {
}) })
}) })
describe('with a billing info updated request', function() {
beforeEach(function(done) {
this.req = {
body: {
billing_info_updated_notification: {
account: {
account_code: 'mock-account-code'
}
}
}
}
this.res = {
sendStatus() {
done()
}
}
sinon.spy(this.res, 'sendStatus')
this.SubscriptionController.recurlyCallback(this.req, this.res)
})
it('should call attemptPaypalInvoiceCollection', function(done) {
this.SubscriptionHandler.attemptPaypalInvoiceCollection
.calledWith('mock-account-code')
.should.equal(true)
done()
})
it('should send a 200', function(done) {
this.res.sendStatus.calledWith(200)
done()
})
})
describe('with a non-actionable request', function() { describe('with a non-actionable request', function() {
beforeEach(function(done) { beforeEach(function(done) {
this.user.id = this.activeRecurlySubscription.account.account_code this.user.id = this.activeRecurlySubscription.account.account_code
@ -567,7 +601,8 @@ describe('SubscriptionController', function() {
}) })
it('should not call the subscriptionshandler', function() { it('should not call the subscriptionshandler', function() {
return this.SubscriptionHandler.recurlyCallback.called.should.equal( this.SubscriptionHandler.syncSubscription.called.should.equal(false)
this.SubscriptionHandler.attemptPaypalInvoiceCollection.called.should.equal(
false false
) )
}) })

View file

@ -70,7 +70,10 @@ describe('SubscriptionHandler', function() {
redeemCoupon: sinon.stub().callsArgWith(2), redeemCoupon: sinon.stub().callsArgWith(2),
createSubscription: sinon createSubscription: sinon
.stub() .stub()
.callsArgWith(3, null, this.activeRecurlySubscription) .callsArgWith(3, null, this.activeRecurlySubscription),
getBillingInfo: sinon.stub().yields(),
getAccountPastDueInvoices: sinon.stub().yields(),
attemptInvoiceCollection: sinon.stub().yields()
} }
this.DropboxHandler = { unlinkAccount: sinon.stub().callsArgWith(1) } this.DropboxHandler = { unlinkAccount: sinon.stub().callsArgWith(1) }
@ -380,7 +383,7 @@ describe('SubscriptionHandler', function() {
}) })
}) })
describe('recurlyCallback', function() { describe('syncSubscription', function() {
describe('with an actionable request', function() { describe('with an actionable request', function() {
beforeEach(function(done) { beforeEach(function(done) {
this.user.id = this.activeRecurlySubscription.account.account_code this.user.id = this.activeRecurlySubscription.account.account_code
@ -389,7 +392,7 @@ describe('SubscriptionHandler', function() {
userId.should.equal(this.user.id) userId.should.equal(this.user.id)
return callback(null, this.user) return callback(null, this.user)
} }
return this.SubscriptionHandler.recurlyCallback( return this.SubscriptionHandler.syncSubscription(
this.activeRecurlySubscription, this.activeRecurlySubscription,
{}, {},
done done
@ -419,6 +422,60 @@ describe('SubscriptionHandler', function() {
}) })
}) })
describe('attemptPaypalInvoiceCollection', function() {
describe('for credit card users', function() {
beforeEach(function(done) {
this.RecurlyWrapper.getBillingInfo.yields(null, {
paypal_billing_agreement_id: null
})
this.SubscriptionHandler.attemptPaypalInvoiceCollection(
this.activeRecurlySubscription.account.account_code,
done
)
})
it('gets billing infos', function() {
sinon.assert.calledWith(
this.RecurlyWrapper.getBillingInfo,
this.activeRecurlySubscription.account.account_code
)
})
it('skips user', function() {
sinon.assert.notCalled(this.RecurlyWrapper.getAccountPastDueInvoices)
})
})
describe('for paypal users', function() {
beforeEach(function(done) {
this.RecurlyWrapper.getBillingInfo.yields(null, {
paypal_billing_agreement_id: 'mock-billing-agreement'
})
this.RecurlyWrapper.getAccountPastDueInvoices.yields(null, [
{ invoice_number: 'mock-invoice-number' }
])
this.SubscriptionHandler.attemptPaypalInvoiceCollection(
this.activeRecurlySubscription.account.account_code,
done
)
})
it('gets past due invoices', function() {
sinon.assert.calledWith(
this.RecurlyWrapper.getAccountPastDueInvoices,
this.activeRecurlySubscription.account.account_code
)
})
it('calls attemptInvoiceCollection', function() {
sinon.assert.calledWith(
this.RecurlyWrapper.attemptInvoiceCollection,
'mock-invoice-number'
)
})
})
})
describe('validateNoSubscriptionInRecurly', function() { describe('validateNoSubscriptionInRecurly', function() {
beforeEach(function() { beforeEach(function() {
this.subscriptions = [] this.subscriptions = []