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 ) })