Merge pull request #2020 from overleaf/ta-recurly-sca

SCA Integration

GitOrigin-RevId: d7935584f87ec7c7339d050430efc87420a39de1
This commit is contained in:
Timothée Alby 2019-08-22 13:57:50 +02:00 committed by sharelatex
parent b90d07fc6f
commit 03460ba229
10 changed files with 327 additions and 108 deletions

View file

@ -0,0 +1,14 @@
const OError = require('@overleaf/o-error')
class RecurlyTransactionError extends OError {
constructor(options) {
super({
message: 'Unknown transaction error',
...options
})
}
}
module.exports = {
RecurlyTransactionError
}

View file

@ -24,6 +24,7 @@ const Settings = require('settings-sharelatex')
const xml2js = require('xml2js')
const logger = require('logger-sharelatex')
const Async = require('async')
const SubscriptionErrors = require('./Errors')
module.exports = RecurlyWrapper = {
apiUrl: Settings.apis.recurly.url || 'https://api.recurly.com/v2',
@ -31,10 +32,9 @@ module.exports = RecurlyWrapper = {
_paypal: {
checkAccountExists(cache, next) {
const { user } = cache
const { recurly_token_id } = cache
const { subscriptionDetails } = cache
logger.log(
{ user_id: user._id, recurly_token_id },
{ user_id: user._id },
'checking if recurly account exists for user'
)
return RecurlyWrapper.apiRequest(
@ -46,7 +46,7 @@ module.exports = RecurlyWrapper = {
function(error, response, responseBody) {
if (error) {
logger.warn(
{ error, user_id: user._id, recurly_token_id },
{ error, user_id: user._id },
'error response from recurly while checking account'
)
return next(error)
@ -54,25 +54,19 @@ module.exports = RecurlyWrapper = {
if (response.statusCode === 404) {
// actually not an error in this case, just no existing account
logger.log(
{ user_id: user._id, recurly_token_id },
{ user_id: user._id },
'user does not currently exist in recurly, proceed'
)
cache.userExists = false
return next(null, cache)
}
logger.log(
{ user_id: user._id, recurly_token_id },
'user appears to exist in recurly'
)
logger.log({ user_id: user._id }, 'user appears to exist in recurly')
return RecurlyWrapper._parseAccountXml(responseBody, function(
err,
account
) {
if (err) {
logger.warn(
{ err, user_id: user._id, recurly_token_id },
'error parsing account'
)
logger.warn({ err, user_id: user._id }, 'error parsing account')
return next(err)
}
cache.userExists = true
@ -84,7 +78,6 @@ module.exports = RecurlyWrapper = {
},
createAccount(cache, next) {
const { user } = cache
const { recurly_token_id } = cache
const { subscriptionDetails } = cache
const { address } = subscriptionDetails
if (!address) {
@ -93,16 +86,10 @@ module.exports = RecurlyWrapper = {
)
}
if (cache.userExists) {
logger.log(
{ user_id: user._id, recurly_token_id },
'user already exists in recurly'
)
logger.log({ user_id: user._id }, 'user already exists in recurly')
return next(null, cache)
}
logger.log(
{ user_id: user._id, recurly_token_id },
'creating user in recurly'
)
logger.log({ user_id: user._id }, 'creating user in recurly')
const data = {
account_code: user._id,
email: user.email,
@ -128,7 +115,7 @@ module.exports = RecurlyWrapper = {
(error, response, responseBody) => {
if (error) {
logger.warn(
{ error, user_id: user._id, recurly_token_id },
{ error, user_id: user._id },
'error response from recurly while creating account'
)
return next(error)
@ -138,10 +125,7 @@ module.exports = RecurlyWrapper = {
account
) {
if (err) {
logger.warn(
{ err, user_id: user._id, recurly_token_id },
'error creating account'
)
logger.warn({ err, user_id: user._id }, 'error creating account')
return next(err)
}
cache.account = account
@ -152,12 +136,9 @@ module.exports = RecurlyWrapper = {
},
createBillingInfo(cache, next) {
const { user } = cache
const { recurly_token_id } = cache
const { recurlyTokenIds } = cache
const { subscriptionDetails } = cache
logger.log(
{ user_id: user._id, recurly_token_id },
'creating billing info in recurly'
)
logger.log({ user_id: user._id }, 'creating billing info in recurly')
const accountCode = __guard__(
cache != null ? cache.account : undefined,
x1 => x1.account_code
@ -165,7 +146,7 @@ module.exports = RecurlyWrapper = {
if (!accountCode) {
return next(new Error('no account code at createBillingInfo stage'))
}
const data = { token_id: recurly_token_id }
const data = { token_id: recurlyTokenIds.billing }
const requestBody = RecurlyWrapper._buildXml('billing_info', data)
return RecurlyWrapper.apiRequest(
{
@ -176,7 +157,7 @@ module.exports = RecurlyWrapper = {
(error, response, responseBody) => {
if (error) {
logger.warn(
{ error, user_id: user._id, recurly_token_id },
{ error, user_id: user._id },
'error response from recurly while creating billing info'
)
return next(error)
@ -187,7 +168,7 @@ module.exports = RecurlyWrapper = {
) {
if (err) {
logger.warn(
{ err, user_id: user._id, accountCode, recurly_token_id },
{ err, user_id: user._id, accountCode },
'error creating billing info'
)
return next(err)
@ -201,12 +182,8 @@ module.exports = RecurlyWrapper = {
setAddress(cache, next) {
const { user } = cache
const { recurly_token_id } = cache
const { subscriptionDetails } = cache
logger.log(
{ user_id: user._id, recurly_token_id },
'setting billing address in recurly'
)
logger.log({ user_id: user._id }, 'setting billing address in recurly')
const accountCode = __guard__(
cache != null ? cache.account : undefined,
x1 => x1.account_code
@ -239,7 +216,7 @@ module.exports = RecurlyWrapper = {
(error, response, responseBody) => {
if (error) {
logger.warn(
{ error, user_id: user._id, recurly_token_id },
{ error, user_id: user._id },
'error response from recurly while setting address'
)
return next(error)
@ -250,7 +227,7 @@ module.exports = RecurlyWrapper = {
) {
if (err) {
logger.warn(
{ err, user_id: user._id, recurly_token_id },
{ err, user_id: user._id },
'error updating billing info'
)
return next(err)
@ -263,12 +240,8 @@ module.exports = RecurlyWrapper = {
},
createSubscription(cache, next) {
const { user } = cache
const { recurly_token_id } = cache
const { subscriptionDetails } = cache
logger.log(
{ user_id: user._id, recurly_token_id },
'creating subscription in recurly'
)
logger.log({ user_id: user._id }, 'creating subscription in recurly')
const data = {
plan_code: subscriptionDetails.plan_code,
currency: subscriptionDetails.currencyCode,
@ -288,7 +261,7 @@ module.exports = RecurlyWrapper = {
(error, response, responseBody) => {
if (error) {
logger.warn(
{ error, user_id: user._id, recurly_token_id },
{ error, user_id: user._id },
'error response from recurly while creating subscription'
)
return next(error)
@ -299,7 +272,7 @@ module.exports = RecurlyWrapper = {
) {
if (err) {
logger.warn(
{ err, user_id: user._id, recurly_token_id },
{ err, user_id: user._id },
'error creating subscription'
)
return next(err)
@ -315,17 +288,17 @@ module.exports = RecurlyWrapper = {
_createPaypalSubscription(
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
callback
) {
logger.log(
{ user_id: user._id, recurly_token_id },
{ user_id: user._id },
'starting process of creating paypal subscription'
)
// We use `async.waterfall` to run each of these actions in sequence
// passing a `cache` object along the way. The cache is initialized
// with required data, and `async.apply` to pass the cache to the first function
const cache = { user, recurly_token_id, subscriptionDetails }
const cache = { user, recurlyTokenIds, subscriptionDetails }
return Async.waterfall(
[
Async.apply(RecurlyWrapper._paypal.checkAccountExists, cache),
@ -337,7 +310,7 @@ module.exports = RecurlyWrapper = {
function(err, result) {
if (err) {
logger.warn(
{ err, user_id: user._id, recurly_token_id },
{ err, user_id: user._id },
'error in paypal subscription creation process'
)
return callback(err)
@ -345,13 +318,13 @@ module.exports = RecurlyWrapper = {
if (!result.subscription) {
err = new Error('no subscription object in result')
logger.warn(
{ err, user_id: user._id, recurly_token_id },
{ err, user_id: user._id },
'error in paypal subscription creation process'
)
return callback(err)
}
logger.log(
{ user_id: user._id, recurly_token_id },
{ user_id: user._id },
'done creating paypal subscription for user'
)
return callback(null, result.subscription)
@ -362,7 +335,7 @@ module.exports = RecurlyWrapper = {
_createCreditCardSubscription(
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
callback
) {
const data = {
@ -375,37 +348,47 @@ module.exports = RecurlyWrapper = {
first_name: subscriptionDetails.first_name || user.first_name,
last_name: subscriptionDetails.last_name || user.last_name,
billing_info: {
token_id: recurly_token_id
token_id: recurlyTokenIds.billing
}
}
}
if (recurlyTokenIds.threeDSecureActionResult) {
data.account.billing_info.three_d_secure_action_result_token_id =
recurlyTokenIds.threeDSecureActionResult
}
const requestBody = RecurlyWrapper._buildXml('subscription', data)
return RecurlyWrapper.apiRequest(
{
url: 'subscriptions',
method: 'POST',
body: requestBody
body: requestBody,
expect422: true
},
(error, response, responseBody) => {
if (error != null) {
return callback(error)
}
return RecurlyWrapper._parseSubscriptionXml(responseBody, callback)
if (response.statusCode === 422) {
RecurlyWrapper._handle422Response(responseBody, callback)
} else {
RecurlyWrapper._parseSubscriptionXml(responseBody, callback)
}
}
)
},
createSubscription(user, subscriptionDetails, recurly_token_id, callback) {
createSubscription(user, subscriptionDetails, recurlyTokenIds, callback) {
const { isPaypal } = subscriptionDetails
logger.log(
{ user_id: user._id, isPaypal, recurly_token_id },
{ user_id: user._id, isPaypal },
'setting up subscription in recurly'
)
const fn = isPaypal
? RecurlyWrapper._createPaypalSubscription
: RecurlyWrapper._createCreditCardSubscription
return fn(user, subscriptionDetails, recurly_token_id, callback)
return fn(user, subscriptionDetails, recurlyTokenIds, callback)
},
apiRequest(options, callback) {
@ -418,15 +401,17 @@ module.exports = RecurlyWrapper = {
'Content-Type': 'application/xml; charset=utf-8',
'X-Api-Version': Settings.apis.recurly.apiVersion
}
const { expect404 } = options
const { expect404, expect422 } = options
delete options.expect404
delete options.expect422
return request(options, function(error, response, body) {
if (
error == null &&
response.statusCode !== 200 &&
response.statusCode !== 201 &&
response.statusCode !== 204 &&
(response.statusCode !== 404 || !expect404)
(response.statusCode !== 404 || !expect404) &&
(response.statusCode !== 422 || !expect422)
) {
logger.warn(
{
@ -445,6 +430,12 @@ module.exports = RecurlyWrapper = {
'got 404 response from recurly, expected as valid response'
)
}
if (response.statusCode === 422 && expect422) {
logger.log(
{ url: options.url, method: options.method },
'got 422 response from recurly, expected as valid response'
)
}
return callback(error, response, body)
})
},
@ -846,6 +837,43 @@ module.exports = RecurlyWrapper = {
)
},
_handle422Response(body, callback) {
RecurlyWrapper._parseErrorsXml(body, (error, data) => {
if (error) {
return callback(error)
}
let errorData = {}
if (data.transaction_error) {
errorData = {
message: data.transaction_error.merchant_message,
info: {
category: data.transaction_error.error_category,
gatewayCode: data.transaction_error.gateway_error_code,
public: {
code: data.transaction_error.error_code,
message: data.transaction_error.customer_message
}
}
}
if (data.transaction_error.three_d_secure_action_token_id) {
errorData.info.public.threeDSecureActionTokenId =
data.transaction_error.three_d_secure_action_token_id
}
} else if (data.error && data.error._) {
// fallback for errors that don't have a `transaction_error` field, but
// instead a `error` field with a message (e.g. VATMOSS errors)
errorData = {
info: {
public: {
message: data.error._
}
}
}
}
callback(new SubscriptionErrors.RecurlyTransactionError(errorData))
})
},
_parseSubscriptionsXml(xml, callback) {
return RecurlyWrapper._parseXmlAndGetAttribute(
xml,
@ -882,6 +910,10 @@ module.exports = RecurlyWrapper = {
return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'coupon', callback)
},
_parseErrorsXml(xml, callback) {
return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'errors', callback)
},
_parseXmlAndGetAttribute(xml, attribute, callback) {
return RecurlyWrapper._parseXml(xml, function(error, data) {
if (error != null) {

View file

@ -29,6 +29,8 @@ const FeaturesUpdater = require('./FeaturesUpdater')
const planFeatures = require('./planFeatures')
const GroupPlansData = require('./GroupPlansData')
const V1SubscriptionManager = require('./V1SubscriptionManager')
const SubscriptionErrors = require('./Errors')
const HttpErrors = require('@overleaf/o-error/http')
module.exports = SubscriptionController = {
plansPage(req, res, next) {
@ -199,10 +201,14 @@ module.exports = SubscriptionController = {
createSubscription(req, res, next) {
const user = AuthenticationController.getSessionUser(req)
const { recurly_token_id } = req.body
const recurlyTokenIds = {
billing: req.body.recurly_token_id,
threeDSecureActionResult:
req.body.recurly_three_d_secure_action_result_token_id
}
const { subscriptionDetails } = req.body
logger.log(
{ recurly_token_id, user_id: user._id, subscriptionDetails },
{ user_id: user._id, subscriptionDetails },
'creating subscription'
)
@ -220,16 +226,23 @@ module.exports = SubscriptionController = {
return SubscriptionHandler.createSubscription(
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
function(err) {
if (err != null) {
logger.warn(
{ err, user_id: user._id },
'something went wrong creating subscription'
)
return next(err)
if (!err) {
return res.sendStatus(201)
}
return res.sendStatus(201)
if (err instanceof SubscriptionErrors.RecurlyTransactionError) {
return next(
new HttpErrors.UnprocessableEntityError({}).withCause(err)
)
}
logger.warn(
{ err, user_id: user._id },
'something went wrong creating subscription'
)
next(err)
}
)
})

View file

@ -56,7 +56,7 @@ module.exports = {
})
},
createSubscription(user, subscriptionDetails, recurly_token_id, callback) {
createSubscription(user, subscriptionDetails, recurlyTokenIds, callback) {
const self = this
const clientTokenId = ''
return this.validateNoSubscriptionInRecurly(user._id, function(
@ -72,7 +72,7 @@ module.exports = {
return RecurlyWrapper.createSubscription(
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
function(error, recurlySubscription) {
if (error != null) {
return callback(error)

View file

@ -15,7 +15,7 @@ block content
.container(ng-controller="NewSubscriptionController" ng-cloak)
.row.card-group
.col-md-5.col-md-push-4
.card.card-highlighted.card-border
.card.card-highlighted.card-border(ng-hide="threeDSecureFlow")
.alert.alert-danger(ng-show="recurlyLoadError")
strong #{translate('payment_provider_unreachable_error')}
.page-header(ng-hide="recurlyLoadError")
@ -212,6 +212,12 @@ block content
| {{ monthlyBilling ? '#{translate("upgrade_cc_btn")}' : '#{translate("upgrade_now")}'}}
span(ng-if="paymentMethod.value !== 'credit_card'") #{translate("upgrade_paypal_btn")}
div.three-d-secure-container.card.card-highlighted.card-border(ng-show="threeDSecureFlow")
.alert.alert-info.small(aria-live="assertive")
strong #{translate('card_must_be_authenticated_by_3dsecure')}
div.three-d-secure-recurly-container
.col-md-3.col-md-pull-4
if showStudentPlan == 'true'

View file

@ -64,6 +64,14 @@ define(['base', 'directives/creditCards'], App =>
$scope.processing = false
$scope.threeDSecureFlow = false
$scope.threeDSecureContainer = document.querySelector(
'.three-d-secure-container'
)
$scope.threeDSecureRecurlyContainer = document.querySelector(
'.three-d-secure-recurly-container'
)
recurly.configure({
publicKey: window.recurlyApiKey,
style: {
@ -172,7 +180,17 @@ define(['base', 'directives/creditCards'], App =>
$scope.genericError = ''
}
const completeSubscription = function(err, recurly_token_id) {
let cachedRecurlyBillingToken
const completeSubscription = function(
err,
recurlyBillingToken,
recurly3DSecureResultToken
) {
if (recurlyBillingToken) {
// temporary store the billing token as it might be needed when
// re-sending the request after SCA authentication
cachedRecurlyBillingToken = recurlyBillingToken
}
$scope.validation.errorFields = {}
if (err != null) {
event_tracking.sendMB('subscription-error', err)
@ -190,7 +208,9 @@ define(['base', 'directives/creditCards'], App =>
} else {
const postData = {
_csrf: window.csrfToken,
recurly_token_id: recurly_token_id.id,
recurly_token_id: cachedRecurlyBillingToken.id,
recurly_three_d_secure_action_result_token_id:
recurly3DSecureResultToken && recurly3DSecureResultToken.id,
subscriptionDetails: {
currencyCode: pricing.items.currency,
plan_code: pricing.items.plan.code,
@ -232,9 +252,16 @@ define(['base', 'directives/creditCards'], App =>
)
window.location.href = '/user/subscription/thank-you'
})
.catch(function() {
.catch(response => {
$scope.processing = false
$scope.genericError = 'Something went wrong processing the request'
const { data } = response
$scope.genericError =
(data && data.message) ||
'Something went wrong processing the request'
if (data.threeDSecureActionTokenId) {
initThreeDSecure(data.threeDSecureActionTokenId)
}
})
}
}
@ -249,6 +276,44 @@ define(['base', 'directives/creditCards'], App =>
}
}
const initThreeDSecure = function(threeDSecureActionTokenId) {
// instanciate and configure Recurly 3DSecure flow
const risk = recurly.Risk()
const threeDSecure = risk.ThreeDSecure({
actionTokenId: threeDSecureActionTokenId
})
// on SCA verification error: show payment UI with the error message
threeDSecure.on('error', error => {
$scope.genericError = `Error: ${error.message}`
$scope.threeDSecureFlow = false
$scope.$apply()
})
// on SCA verification success: show payment UI in processing mode and
// resubmit the payment with the new token final success or error will be
// handled by `completeSubscription`
threeDSecure.on('token', recurly3DSecureResultToken => {
completeSubscription(null, null, recurly3DSecureResultToken)
$scope.genericError = null
$scope.threeDSecureFlow = false
$scope.processing = true
$scope.$apply()
})
// make sure the threeDSecureRecurlyContainer is empty (in case of
// retries) and show 3DSecure UI
$scope.threeDSecureRecurlyContainer.innerHTML = ''
$scope.threeDSecureFlow = true
threeDSecure.attach($scope.threeDSecureRecurlyContainer)
// scroll the UI into view (timeout needed to make sure the element is
// visible)
window.setTimeout(() => {
$scope.threeDSecureContainer.scrollIntoView()
}, 0)
}
$scope.countries = [
{ code: 'AF', name: 'Afghanistan' },
{ code: 'AL', name: 'Albania' },

View file

@ -80,3 +80,13 @@
.capitalised {
text-transform:capitalize;
}
.three-d-secure-container {
> .three-d-secure-recurly-container {
height: 400px;
> div[data-recurly="three-d-secure-container"] {
height: 100%;
}
}
}

View file

@ -21,6 +21,7 @@ const querystring = require('querystring')
const modulePath = '../../../../app/src/Features/Subscription/RecurlyWrapper'
const SandboxedModule = require('sandboxed-module')
const tk = require('timekeeper')
const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors')
const fixtures = {
'subscriptions/44f83d7cba354d5b84812419f923ea96':
@ -156,7 +157,8 @@ describe('RecurlyWrapper', function() {
log: sinon.stub()
},
request: sinon.stub(),
xml2js: require('xml2js')
xml2js: require('xml2js'),
'./Errors': SubscriptionErrors
}
}
))
@ -498,12 +500,15 @@ describe('RecurlyWrapper', function() {
}
}
this.subscription = {}
this.recurly_token_id = 'a-token-id'
this.recurlyTokenIds = {
billing: 'a-token-id',
threeDSecureActionResult: 'a-3d-token-id'
}
return (this.call = callback => {
return this.RecurlyWrapper.createSubscription(
this.user,
this.subscriptionDetails,
this.recurly_token_id,
this.recurlyTokenIds,
callback
)
})
@ -647,7 +652,10 @@ describe('RecurlyWrapper', function() {
}
}
this.subscription = {}
this.recurly_token_id = 'a-token-id'
this.recurlyTokenIds = {
billing: 'a-token-id',
threeDSecureActionResult: 'a-3d-token-id'
}
this.apiRequest = sinon.stub(this.RecurlyWrapper, 'apiRequest')
this.response = { statusCode: 200 }
this.body = '<xml>is_bad</xml>'
@ -661,7 +669,7 @@ describe('RecurlyWrapper', function() {
return this.RecurlyWrapper._createCreditCardSubscription(
this.user,
this.subscriptionDetails,
this.recurly_token_id,
this.recurlyTokenIds,
callback
)
})
@ -687,6 +695,7 @@ describe('RecurlyWrapper', function() {
<last_name>Johnson</last_name>
<billing_info>
<token_id>a-token-id</token_id>
<three_d_secure_action_result_token_id>a-3d-token-id</three_d_secure_action_result_token_id>
</billing_info>
</account>
</subscription>\
@ -724,6 +733,41 @@ describe('RecurlyWrapper', function() {
})
})
describe('when api request returns 422', function() {
beforeEach(function() {
const body = `\
<?xml version="1.0" encoding="UTF-8"?>
<errors>
<transaction_error>
<error_code>three_d_secure_action_required</error_code>
<error_category>3d_secure_action_required</error_category>
<merchant_message>Your payment gateway is requesting that the transaction be completed with 3D Secure in accordance with PSD2.</merchant_message>
<customer_message>Your card must be authenticated with 3D Secure before continuing.</customer_message>
<gateway_error_code nil="nil"></gateway_error_code>
<three_d_secure_action_token_id>mock_three_d_secure_action_token</three_d_secure_action_token_id>
</transaction_error>
<error field="subscription.account.base" symbol="three_d_secure_action_required">Your card must be authenticated with 3D Secure before continuing.</error>
</errors>
`
this.apiRequest.yields(null, { statusCode: 422 }, body)
})
it('should produce an error', function(done) {
return this.call((err, sub) => {
expect(err).to.be.instanceof(
SubscriptionErrors.RecurlyTransactionError
)
expect(err.info.public.message).to.be.equal(
'Your card must be authenticated with 3D Secure before continuing.'
)
expect(err.info.public.threeDSecureActionTokenId).to.be.equal(
'mock_three_d_secure_action_token'
)
return done()
})
})
})
describe('when api request produces an error', function() {
beforeEach(function() {
return this.apiRequest.callsArgWith(1, new Error('woops'))
@ -802,31 +846,34 @@ describe('RecurlyWrapper', function() {
}
}
this.subscription = {}
this.recurly_token_id = 'a-token-id'
this.recurlyTokenIds = {
billing: 'a-token-id',
threeDSecureActionResult: 'a-3d-token-id'
}
// set up data callbacks
const { user } = this
const { subscriptionDetails } = this
const { recurly_token_id } = this
const { recurlyTokenIds } = this
this.checkAccountExists.callsArgWith(1, null, {
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
userExists: false,
account: { accountCode: 'xx' }
})
this.createAccount.callsArgWith(1, null, {
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
userExists: false,
account: { accountCode: 'xx' }
})
this.createBillingInfo.callsArgWith(1, null, {
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
userExists: false,
account: { accountCode: 'xx' },
billingInfo: { token_id: 'abc' }
@ -834,7 +881,7 @@ describe('RecurlyWrapper', function() {
this.setAddress.callsArgWith(1, null, {
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
userExists: false,
account: { accountCode: 'xx' },
billingInfo: { token_id: 'abc' }
@ -842,7 +889,7 @@ describe('RecurlyWrapper', function() {
this.createSubscription.callsArgWith(1, null, {
user,
subscriptionDetails,
recurly_token_id,
recurlyTokenIds,
userExists: false,
account: { accountCode: 'xx' },
billingInfo: { token_id: 'abc' },
@ -853,7 +900,7 @@ describe('RecurlyWrapper', function() {
return this.RecurlyWrapper._createPaypalSubscription(
this.user,
this.subscriptionDetails,
this.recurly_token_id,
this.recurlyTokenIds,
callback
)
})
@ -937,7 +984,10 @@ describe('RecurlyWrapper', function() {
first_name: 'Foo',
last_name: 'Bar'
}),
recurly_token_id: (this.recurly_token_id = 'some_token'),
recurlyTokenIds: (this.recurlyTokenIds = {
billing: 'a-token-id',
threeDSecureActionResult: 'a-3d-token-id'
}),
subscriptionDetails: (this.subscriptionDetails = {
currencyCode: 'EUR',
plan_code: 'some_plan_code',
@ -1255,7 +1305,7 @@ describe('RecurlyWrapper', function() {
const { body } = this.apiRequest.lastCall.args[0]
expect(body).to.equal(`\
<billing_info>
<token_id>some_token</token_id>
<token_id>a-token-id</token_id>
</billing_info>\
`)
return done()

View file

@ -19,6 +19,9 @@ const MockRequest = require('../helpers/MockRequest')
const MockResponse = require('../helpers/MockResponse')
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionController'
const SubscriptionErrors = require('../../../../app/src/Features/Subscription/Errors')
const OError = require('@overleaf/o-error')
const HttpErrors = require('@overleaf/o-error/http')
const mockSubscriptions = {
'subscription-123-active': {
@ -89,7 +92,9 @@ describe('SubscriptionController', function() {
gaExperiments: {}
}
this.GeoIpLookup = { getCurrencyCode: sinon.stub() }
this.UserGetter = { getUser: sinon.stub().callsArgWith(2, null, this.user) }
this.UserGetter = {
getUser: sinon.stub().callsArgWith(2, null, this.user)
}
this.SubscriptionController = SandboxedModule.require(modulePath, {
globals: {
console: console
@ -111,7 +116,9 @@ describe('SubscriptionController', function() {
'./RecurlyWrapper': (this.RecurlyWrapper = {}),
'./FeaturesUpdater': (this.FeaturesUpdater = {}),
'./GroupPlansData': (this.GroupPlansData = {}),
'./V1SubscriptionManager': (this.V1SubscriptionManager = {})
'./V1SubscriptionManager': (this.V1SubscriptionManager = {}),
'./Errors': SubscriptionErrors,
'@overleaf/o-error/http': HttpErrors
}
})
@ -378,7 +385,12 @@ describe('SubscriptionController', function() {
card: '1234',
cvv: '123'
}
this.req.body.recurly_token_id = '1234'
this.recurlyTokenIds = {
billing: '1234',
threeDSecureActionResult: '5678'
}
this.req.body.recurly_token_id = this.recurlyTokenIds.billing
this.req.body.recurly_three_d_secure_action_result_token_id = this.recurlyTokenIds.threeDSecureActionResult
this.req.body.subscriptionDetails = this.subscriptionDetails
this.LimitationsManager.userHasV1OrV2Subscription.yields(null, false)
return this.SubscriptionController.createSubscription(this.req, this.res)
@ -386,10 +398,10 @@ describe('SubscriptionController', function() {
it('should send the user and subscriptionId to the handler', function(done) {
this.SubscriptionHandler.createSubscription
.calledWith(
.calledWithMatch(
this.user,
this.subscriptionDetails,
this.req.body.recurly_token_id
this.recurlyTokenIds
)
.should.equal(true)
return done()
@ -401,6 +413,27 @@ describe('SubscriptionController', function() {
})
})
describe('createSubscription with errors', function() {
it('should handle 3DSecure errors', function(done) {
this.next = sinon.stub()
this.LimitationsManager.userHasV1OrV2Subscription.yields(null, false)
this.SubscriptionHandler.createSubscription.yields(
new SubscriptionErrors.RecurlyTransactionError({})
)
this.SubscriptionController.createSubscription(this.req, null, error => {
expect(error).to.exist
expect(error).to.be.instanceof(HttpErrors.UnprocessableEntityError)
expect(
OError.hasCauseInstanceOf(
error,
SubscriptionErrors.RecurlyTransactionError
)
).to.be.true
})
return done()
})
})
describe('updateSubscription via post', function() {
beforeEach(function(done) {
this.res = {

View file

@ -118,7 +118,7 @@ describe('SubscriptionHandler', function() {
cvv: '123',
number: '12345'
}
this.recurly_token_id = '45555666'
this.recurlyTokenIds = { billing: '45555666' }
return (this.SubscriptionHandler.validateNoSubscriptionInRecurly = sinon
.stub()
.yields(null, true))
@ -129,18 +129,14 @@ describe('SubscriptionHandler', function() {
return this.SubscriptionHandler.createSubscription(
this.user,
this.subscriptionDetails,
this.recurly_token_id,
this.recurlyTokenIds,
this.callback
)
})
it('should create the subscription with the wrapper', function() {
return this.RecurlyWrapper.createSubscription
.calledWith(
this.user,
this.subscriptionDetails,
this.recurly_token_id
)
.calledWith(this.user, this.subscriptionDetails, this.recurlyTokenIds)
.should.equal(true)
})
@ -163,7 +159,7 @@ describe('SubscriptionHandler', function() {
return this.SubscriptionHandler.createSubscription(
this.user,
this.subscriptionDetails,
this.recurly_token_id,
this.recurlyTokenIds,
this.callback
)
})