/* eslint-disable camelcase, node/handle-callback-err, max-len, no-unused-vars, node/no-deprecated-api, */ // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* * decaffeinate suggestions: * DS101: Remove unnecessary use of Array.from * DS102: Remove unnecessary code created because of implicit returns * DS103: Rewrite code to no longer use __guard__ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ const OError = require('@overleaf/o-error') const querystring = require('querystring') const crypto = require('crypto') const request = require('request') const Settings = require('settings-sharelatex') const xml2js = require('xml2js') const logger = require('logger-sharelatex') const Async = require('async') const Errors = require('../Errors/Errors') const SubscriptionErrors = require('./Errors') const { promisify } = require('util') function updateAccountEmailAddress(accountId, newEmail, callback) { const data = { email: newEmail } const requestBody = RecurlyWrapper._buildXml('account', data) RecurlyWrapper.apiRequest( { url: `accounts/${accountId}`, method: 'PUT', body: requestBody }, (error, response, body) => { if (error != null) { return callback(error) } RecurlyWrapper._parseAccountXml(body, callback) } ) } const RecurlyWrapper = { apiUrl: Settings.apis.recurly.url || 'https://api.recurly.com/v2', _paypal: { checkAccountExists(cache, next) { const { user } = cache const { subscriptionDetails } = cache logger.log( { user_id: user._id }, 'checking if recurly account exists for user' ) return RecurlyWrapper.apiRequest( { url: `accounts/${user._id}`, method: 'GET', expect404: true }, function (error, response, responseBody) { if (error) { OError.tag( error, 'error response from recurly while checking account', { user_id: user._id } ) return next(error) } if (response.statusCode === 404) { // actually not an error in this case, just no existing account logger.log( { 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 }, 'user appears to exist in recurly') return RecurlyWrapper._parseAccountXml( responseBody, function (err, account) { if (err) { OError.tag(err, 'error parsing account', { user_id: user._id }) return next(err) } cache.userExists = true cache.account = account return next(null, cache) } ) } ) }, createAccount(cache, next) { const { user } = cache const { subscriptionDetails } = cache if (cache.userExists) { return next(null, cache) } let address try { address = getAddressFromSubscriptionDetails(subscriptionDetails, false) } catch (error) { return next(error) } const data = { account_code: user._id, email: user.email, first_name: user.first_name, last_name: user.last_name, address } const requestBody = RecurlyWrapper._buildXml('account', data) return RecurlyWrapper.apiRequest( { url: 'accounts', method: 'POST', body: requestBody }, (error, response, responseBody) => { if (error) { OError.tag( error, 'error response from recurly while creating account', { user_id: user._id } ) return next(error) } return RecurlyWrapper._parseAccountXml( responseBody, function (err, account) { if (err) { OError.tag(err, 'error creating account', { user_id: user._id }) return next(err) } cache.account = account return next(null, cache) } ) } ) }, createBillingInfo(cache, next) { const { user } = cache const { recurlyTokenIds } = cache const { subscriptionDetails } = cache logger.log({ user_id: user._id }, 'creating billing info in recurly') const accountCode = __guard__( cache != null ? cache.account : undefined, x1 => x1.account_code ) if (!accountCode) { return next(new Error('no account code at createBillingInfo stage')) } const data = { token_id: recurlyTokenIds.billing } const requestBody = RecurlyWrapper._buildXml('billing_info', data) return RecurlyWrapper.apiRequest( { url: `accounts/${accountCode}/billing_info`, method: 'POST', body: requestBody }, (error, response, responseBody) => { if (error) { OError.tag( error, 'error response from recurly while creating billing info', { user_id: user._id } ) return next(error) } return RecurlyWrapper._parseBillingInfoXml( responseBody, function (err, billingInfo) { if (err) { OError.tag(err, 'error creating billing info', { user_id: user._id, accountCode }) return next(err) } cache.billingInfo = billingInfo return next(null, cache) } ) } ) }, setAddressAndCompanyBillingInfo(cache, next) { const { user } = cache const { subscriptionDetails } = cache logger.log( { user_id: user._id }, 'setting billing address and company info in recurly' ) const accountCode = __guard__( cache != null ? cache.account : undefined, x1 => x1.account_code ) if (!accountCode) { return next( new Error('no account code at setAddressAndCompanyBillingInfo stage') ) } let addressAndCompanyBillingInfo try { addressAndCompanyBillingInfo = getAddressFromSubscriptionDetails( subscriptionDetails, true ) } catch (error) { return next(error) } const requestBody = RecurlyWrapper._buildXml( 'billing_info', addressAndCompanyBillingInfo ) return RecurlyWrapper.apiRequest( { url: `accounts/${accountCode}/billing_info`, method: 'PUT', body: requestBody }, (error, response, responseBody) => { if (error) { OError.tag( error, 'error response from recurly while setting address', { user_id: user._id } ) return next(error) } return RecurlyWrapper._parseBillingInfoXml( responseBody, function (err, billingInfo) { if (err) { OError.tag(err, 'error updating billing info', { user_id: user._id }) return next(err) } cache.billingInfo = billingInfo return next(null, cache) } ) } ) }, createSubscription(cache, next) { const { user } = cache const { subscriptionDetails } = cache logger.log({ user_id: user._id }, 'creating subscription in recurly') const data = { plan_code: subscriptionDetails.plan_code, currency: subscriptionDetails.currencyCode, coupon_code: subscriptionDetails.coupon_code, account: { account_code: user._id } } const customFields = getCustomFieldsFromSubscriptionDetails( subscriptionDetails ) if (customFields) { data.custom_fields = customFields } const requestBody = RecurlyWrapper._buildXml('subscription', data) return RecurlyWrapper.apiRequest( { url: 'subscriptions', method: 'POST', body: requestBody }, (error, response, responseBody) => { if (error) { OError.tag( error, 'error response from recurly while creating subscription', { user_id: user._id } ) return next(error) } return RecurlyWrapper._parseSubscriptionXml( responseBody, function (err, subscription) { if (err) { OError.tag(err, 'error creating subscription', { user_id: user._id }) return next(err) } cache.subscription = subscription return next(null, cache) } ) } ) } }, _createPaypalSubscription( user, subscriptionDetails, recurlyTokenIds, callback ) { logger.log( { 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, recurlyTokenIds, subscriptionDetails } return Async.waterfall( [ Async.apply(RecurlyWrapper._paypal.checkAccountExists, cache), RecurlyWrapper._paypal.createAccount, RecurlyWrapper._paypal.createBillingInfo, RecurlyWrapper._paypal.setAddressAndCompanyBillingInfo, RecurlyWrapper._paypal.createSubscription ], function (err, result) { if (err) { OError.tag(err, 'error in paypal subscription creation process', { user_id: user._id }) return callback(err) } if (!result.subscription) { err = new Error('no subscription object in result') OError.tag(err, 'error in paypal subscription creation process', { user_id: user._id }) return callback(err) } logger.log( { user_id: user._id }, 'done creating paypal subscription for user' ) return callback(null, result.subscription) } ) }, _createCreditCardSubscription( user, subscriptionDetails, recurlyTokenIds, callback ) { const data = { plan_code: subscriptionDetails.plan_code, currency: subscriptionDetails.currencyCode, coupon_code: subscriptionDetails.coupon_code, account: { account_code: user._id, email: user.email, first_name: subscriptionDetails.first_name || user.first_name, last_name: subscriptionDetails.last_name || user.last_name, billing_info: { token_id: recurlyTokenIds.billing } } } if (recurlyTokenIds.threeDSecureActionResult) { data.account.billing_info.three_d_secure_action_result_token_id = recurlyTokenIds.threeDSecureActionResult } const customFields = getCustomFieldsFromSubscriptionDetails( subscriptionDetails ) if (customFields) { data.custom_fields = customFields } const requestBody = RecurlyWrapper._buildXml('subscription', data) return RecurlyWrapper.apiRequest( { url: 'subscriptions', method: 'POST', body: requestBody, expect422: true }, (error, response, responseBody) => { if (error != null) { return callback(error) } if (response.statusCode === 422) { RecurlyWrapper._handle422Response(responseBody, callback) } else { RecurlyWrapper._parseSubscriptionXml(responseBody, callback) } } ) }, createSubscription(user, subscriptionDetails, recurlyTokenIds, callback) { const { isPaypal } = subscriptionDetails logger.log( { user_id: user._id, isPaypal }, 'setting up subscription in recurly' ) const fn = isPaypal ? RecurlyWrapper._createPaypalSubscription : RecurlyWrapper._createCreditCardSubscription return fn(user, subscriptionDetails, recurlyTokenIds, callback) }, apiRequest(options, callback) { options.url = RecurlyWrapper.apiUrl + '/' + options.url options.headers = { Authorization: `Basic ${new Buffer(Settings.apis.recurly.apiKey).toString( 'base64' )}`, Accept: 'application/xml', 'Content-Type': 'application/xml; charset=utf-8', 'X-Api-Version': Settings.apis.recurly.apiVersion } 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 !== 422 || !expect422) ) { logger.warn( { err: error, body, options, statusCode: response != null ? response.statusCode : undefined }, 'error returned from recurly' ) // TODO: this should be an Error object not a string error = `Recurly API returned with status code: ${response.statusCode}` } return callback(error, response, body) }) }, getSubscriptions(accountId, callback) { return RecurlyWrapper.apiRequest( { url: `accounts/${accountId}/subscriptions` }, (error, response, body) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseXml(body, callback) } ) }, getSubscription(subscriptionId, options, callback) { let url if (callback == null) { callback = options } if (!options) { options = {} } if (options.recurlyJsResult) { url = `recurly_js/result/${subscriptionId}` } else { url = `subscriptions/${subscriptionId}` } return RecurlyWrapper.apiRequest( { url }, (error, response, body) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseSubscriptionXml( body, (error, recurlySubscription) => { if (error != null) { return callback(error) } if (options.includeAccount) { let accountId if ( recurlySubscription.account != null && recurlySubscription.account.url != null ) { accountId = recurlySubscription.account.url.match( /accounts\/(.*)/ )[1] } else { return callback( new Error("I don't understand the response from Recurly") ) } return RecurlyWrapper.getAccount( accountId, function (error, account) { if (error != null) { return callback(error) } recurlySubscription.account = account return callback(null, recurlySubscription) } ) } else { return callback(null, recurlySubscription) } } ) } ) }, getPaginatedEndpoint(resource, queryParams, callback) { queryParams.per_page = queryParams.per_page || 200 let allItems = [] var getPage = (cursor = null) => { const opts = { url: resource, qs: queryParams } if (cursor != null) { opts.qs.cursor = cursor } return RecurlyWrapper.apiRequest(opts, (error, response, body) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseXml(body, function (err, data) { if (err != null) { logger.warn({ err }, 'could not get accoutns') callback(err) } const items = data[resource] allItems = allItems.concat(items) logger.log( `got another ${items.length}, total now ${allItems.length}` ) cursor = __guard__( response.headers.link != null ? response.headers.link.match(/cursor=([0-9.]+%3A[0-9.]+)&/) : undefined, x1 => x1[1] ) if (cursor != null) { cursor = decodeURIComponent(cursor) return getPage(cursor) } else { return callback(err, allItems) } }) }) } return getPage() }, getAccount(accountId, callback) { return RecurlyWrapper.apiRequest( { url: `accounts/${accountId}` }, (error, response, body) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseAccountXml(body, callback) } ) }, updateAccountEmailAddress, getAccountActiveCoupons(accountId, callback) { return RecurlyWrapper.apiRequest( { url: `accounts/${accountId}/redemptions` }, (error, response, body) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseRedemptionsXml( body, function (error, redemptions) { if (error != null) { return callback(error) } const activeRedemptions = redemptions.filter( redemption => redemption.state === 'active' ) const couponCodes = activeRedemptions.map( redemption => redemption.coupon_code ) return Async.map( couponCodes, RecurlyWrapper.getCoupon, function (error, coupons) { if (error != null) { return callback(error) } return callback(null, coupons) } ) } ) } ) }, getCoupon(couponCode, callback) { const opts = { url: `coupons/${couponCode}` } return RecurlyWrapper.apiRequest(opts, (error, response, body) => RecurlyWrapper._parseCouponXml(body, callback) ) }, getBillingInfo(accountId, callback) { return RecurlyWrapper.apiRequest( { url: `accounts/${accountId}/billing_info` }, (error, response, body) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseXml(body, callback) } ) }, 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) { logger.log( { subscriptionId, options }, 'telling recurly to update subscription' ) const data = { plan_code: options.plan_code, timeframe: options.timeframe } const requestBody = RecurlyWrapper._buildXml('subscription', data) return RecurlyWrapper.apiRequest( { url: `subscriptions/${subscriptionId}`, method: 'put', body: requestBody }, (error, response, responseBody) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseSubscriptionXml(responseBody, callback) } ) }, createFixedAmmountCoupon( coupon_code, name, currencyCode, discount_in_cents, plan_code, callback ) { const data = { coupon_code, name, discount_type: 'dollars', discount_in_cents: {}, plan_codes: { plan_code }, applies_to_all_plans: false } data.discount_in_cents[currencyCode] = discount_in_cents const requestBody = RecurlyWrapper._buildXml('coupon', data) logger.log({ coupon_code, requestBody }, 'creating coupon') return RecurlyWrapper.apiRequest( { url: 'coupons', method: 'post', body: requestBody }, (error, response, responseBody) => { if (error != null) { logger.warn({ err: error, coupon_code }, 'error creating coupon') } return callback(error) } ) }, lookupCoupon(coupon_code, callback) { return RecurlyWrapper.apiRequest( { url: `coupons/${coupon_code}` }, (error, response, body) => { if (error != null) { return callback(error) } return RecurlyWrapper._parseXml(body, callback) } ) }, cancelSubscription(subscriptionId, callback) { logger.log({ subscriptionId }, 'telling recurly to cancel subscription') return RecurlyWrapper.apiRequest( { url: `subscriptions/${subscriptionId}/cancel`, method: 'put' }, function (error, response, body) { if (error != null) { return RecurlyWrapper._parseXml(body, function (_err, parsed) { if ( __guard__( parsed != null ? parsed.error : undefined, x1 => x1.description ) === "A canceled subscription can't transition to canceled" ) { // subscription already cancelled, not really an error, proceeding return callback(null) } else { return callback(error) } }) } else { return callback(null) } } ) }, reactivateSubscription(subscriptionId, callback) { logger.log( { subscriptionId }, 'telling recurly to reactivating subscription' ) return RecurlyWrapper.apiRequest( { url: `subscriptions/${subscriptionId}/reactivate`, method: 'put' }, (error, response, body) => callback(error) ) }, redeemCoupon(account_code, coupon_code, callback) { const data = { account_code, currency: 'USD' } const requestBody = RecurlyWrapper._buildXml('redemption', data) logger.log( { account_code, coupon_code, requestBody }, 'redeeming coupon for user' ) return RecurlyWrapper.apiRequest( { url: `coupons/${coupon_code}/redeem`, method: 'post', body: requestBody }, (error, response, responseBody) => { if (error != null) { logger.warn( { err: error, account_code, coupon_code }, 'error redeeming coupon' ) } return callback(error) } ) }, extendTrial(subscriptionId, daysUntilExpire, callback) { if (daysUntilExpire == null) { daysUntilExpire = 7 } const next_renewal_date = new Date() next_renewal_date.setDate(next_renewal_date.getDate() + daysUntilExpire) logger.log( { subscriptionId, daysUntilExpire }, 'Exending Free trial for user' ) return RecurlyWrapper.apiRequest( { url: `/subscriptions/${subscriptionId}/postpone?next_renewal_date=${next_renewal_date}&bulk=false`, method: 'put' }, (error, response, responseBody) => { if (error != null) { logger.warn( { err: error, subscriptionId, daysUntilExpire }, 'error exending trial' ) } return callback(error) } ) }, listAccountActiveSubscriptions(account_id, callback) { if (callback == null) { callback = function (error, subscriptions) {} } return RecurlyWrapper.apiRequest( { url: `accounts/${account_id}/subscriptions`, qs: { state: 'active' }, expect404: true }, function (error, response, body) { if (error != null) { return callback(error) } if (response.statusCode === 404) { return callback(null, []) } else { return RecurlyWrapper._parseSubscriptionsXml(body, callback) } } ) }, _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, 'subscriptions', callback ) }, _parseSubscriptionXml(xml, callback) { return RecurlyWrapper._parseXmlAndGetAttribute( xml, 'subscription', callback ) }, _parseAccountXml(xml, callback) { return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'account', callback) }, _parseBillingInfoXml(xml, callback) { return RecurlyWrapper._parseXmlAndGetAttribute( xml, 'billing_info', callback ) }, _parseRedemptionsXml(xml, callback) { return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'redemptions', callback) }, _parseCouponXml(xml, callback) { return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'coupon', callback) }, _parseErrorsXml(xml, callback) { return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'errors', callback) }, _parseInvoicesXml(xml, callback) { return RecurlyWrapper._parseXmlAndGetAttribute(xml, 'invoices', callback) }, _parseXmlAndGetAttribute(xml, attribute, callback) { return RecurlyWrapper._parseXml(xml, function (error, data) { if (error != null) { return callback(error) } if (data != null && data[attribute] != null) { return callback(null, data[attribute]) } else { return callback( new Error("I don't understand the response from Recurly") ) } }) }, _parseXml(xml, callback) { var convertDataTypes = function (data) { let key, value if (data != null && data.$ != null) { if (data.$.nil === 'nil') { data = null } else if (data.$.href != null) { data.url = data.$.href delete data.$ } else if (data.$.type === 'integer') { data = parseInt(data._, 10) } else if (data.$.type === 'datetime') { data = new Date(data._) } else if (data.$.type === 'array') { delete data.$ let array = [] for (key in data) { value = data[key] if (value instanceof Array) { array = array.concat(convertDataTypes(value)) } else { array.push(convertDataTypes(value)) } } data = array } } if (data instanceof Array) { data = Array.from(data).map(entry => convertDataTypes(entry)) } else if (typeof data === 'object') { for (key in data) { value = data[key] data[key] = convertDataTypes(value) } } return data } const parser = new xml2js.Parser({ explicitRoot: true, explicitArray: false, emptyTag: '' }) return parser.parseString(xml, function (error, data) { if (error != null) { return callback(error) } const result = convertDataTypes(data) return callback(null, result) }) }, _buildXml(rootName, data) { const options = { headless: true, renderOpts: { pretty: true, indent: '\t' }, rootName } const builder = new xml2js.Builder(options) return builder.buildObject(data) } } RecurlyWrapper.promises = { updateAccountEmailAddress: promisify(updateAccountEmailAddress) } module.exports = RecurlyWrapper function getCustomFieldsFromSubscriptionDetails(subscriptionDetails) { if (!subscriptionDetails.ITMCampaign) { return null } const customFields = [ { name: 'itm_campaign', value: subscriptionDetails.ITMCampaign } ] if (subscriptionDetails.ITMContent) { customFields.push({ name: 'itm_content', value: subscriptionDetails.ITMContent }) } return { custom_field: customFields } } function getAddressFromSubscriptionDetails( subscriptionDetails, includeCompanyInfo ) { const { address } = subscriptionDetails if (!address || !address.country) { throw new Errors.InvalidError({ message: 'Invalid country', info: { public: { message: 'Invalid country' } } }) } const addressObject = { address1: address.address1, address2: address.address2 || '', city: address.city || '', state: address.state || '', zip: address.zip || '', country: address.country } if ( includeCompanyInfo && subscriptionDetails.billing_info && subscriptionDetails.billing_info.company && subscriptionDetails.billing_info.company !== '' ) { addressObject.company = subscriptionDetails.billing_info.company if ( subscriptionDetails.billing_info.vat_number && subscriptionDetails.billing_info.vat_number !== '' ) { addressObject.vat_number = subscriptionDetails.billing_info.vat_number } } return addressObject } function __guard__(value, transform) { return typeof value !== 'undefined' && value !== null ? transform(value) : undefined }