diff --git a/services/web/app/src/Features/Subscription/Errors.js b/services/web/app/src/Features/Subscription/Errors.js
new file mode 100644
index 0000000000..41f3957a81
--- /dev/null
+++ b/services/web/app/src/Features/Subscription/Errors.js
@@ -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
+}
diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js
index a90af70350..991fdae222 100644
--- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js
+++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js
@@ -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) {
diff --git a/services/web/app/src/Features/Subscription/SubscriptionController.js b/services/web/app/src/Features/Subscription/SubscriptionController.js
index 94f050dc84..10d99a4b15 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionController.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionController.js
@@ -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)
}
)
})
diff --git a/services/web/app/src/Features/Subscription/SubscriptionHandler.js b/services/web/app/src/Features/Subscription/SubscriptionHandler.js
index 0fa637d64a..f905666148 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionHandler.js
+++ b/services/web/app/src/Features/Subscription/SubscriptionHandler.js
@@ -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)
diff --git a/services/web/app/views/subscriptions/new.pug b/services/web/app/views/subscriptions/new.pug
index baa556dfb5..aadc94987b 100644
--- a/services/web/app/views/subscriptions/new.pug
+++ b/services/web/app/views/subscriptions/new.pug
@@ -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'
diff --git a/services/web/public/src/main/new-subscription.js b/services/web/public/src/main/new-subscription.js
index e3595b9cea..7748d621b4 100644
--- a/services/web/public/src/main/new-subscription.js
+++ b/services/web/public/src/main/new-subscription.js
@@ -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' },
diff --git a/services/web/public/stylesheets/app/subscription.less b/services/web/public/stylesheets/app/subscription.less
index 87bdfdc418..37b7e0be27 100644
--- a/services/web/public/stylesheets/app/subscription.less
+++ b/services/web/public/stylesheets/app/subscription.less
@@ -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%;
+ }
+ }
+}
diff --git a/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js b/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js
index 859a38085c..f2ce8560a8 100644
--- a/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js
+++ b/services/web/test/unit/src/Subscription/RecurlyWrapperTests.js
@@ -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 = 'is_bad'
@@ -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() {
Johnson
a-token-id
+ a-3d-token-id
\
@@ -724,6 +733,41 @@ describe('RecurlyWrapper', function() {
})
})
+ describe('when api request returns 422', function() {
+ beforeEach(function() {
+ const body = `\
+
+
+
+ three_d_secure_action_required
+ 3d_secure_action_required
+ Your payment gateway is requesting that the transaction be completed with 3D Secure in accordance with PSD2.
+ Your card must be authenticated with 3D Secure before continuing.
+
+ mock_three_d_secure_action_token
+
+ Your card must be authenticated with 3D Secure before continuing.
+
+ `
+ 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(`\
- some_token
+ a-token-id
\
`)
return done()
diff --git a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
index 8e2fbdcd5e..9ccbbb66a8 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionControllerTests.js
@@ -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 = {
diff --git a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
index 91affb7904..d038d2a48b 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
+++ b/services/web/test/unit/src/Subscription/SubscriptionHandlerTests.js
@@ -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
)
})