const OError = require('@overleaf/o-error') const request = require('request') const Settings = require('@overleaf/settings') const xml2js = require('xml2js') const logger = require('@overleaf/logger') 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, } let requestBody try { requestBody = RecurlyWrapper._buildXml('account', data) } catch (error) { return callback( OError.tag(error, 'error building xml', { accountId, newEmail }) ) } RecurlyWrapper.apiRequest( { url: `accounts/${accountId}`, method: 'PUT', body: requestBody, }, (error, response, body) => { if (error) { 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 logger.debug( { user_id: user._id }, 'checking if recurly account exists for user' ) 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.debug( { user_id: user._id }, 'user does not currently exist in recurly, proceed' ) cache.userExists = false return next(null, cache) } logger.debug( { user_id: user._id }, 'user appears to exist in recurly' ) 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 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, } let requestBody try { requestBody = RecurlyWrapper._buildXml('account', data) } catch (error) { return next( OError.tag(error, 'error building xml', { user_id: user._id }) ) } 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) } RecurlyWrapper._parseAccountXml( responseBody, function (err, account) { if (err) { OError.tag(err, 'error creating account', { user_id: user._id, }) return next(err) } cache.account = account next(null, cache) } ) } ) }, createBillingInfo(cache, next) { const { user } = cache const { recurlyTokenIds } = cache logger.debug({ user_id: user._id }, 'creating billing info in recurly') const accountCode = cache?.account?.account_code if (!accountCode) { return next(new Error('no account code at createBillingInfo stage')) } const data = { token_id: recurlyTokenIds.billing } let requestBody try { requestBody = RecurlyWrapper._buildXml('billing_info', data) } catch (error) { return next( OError.tag(error, 'error building xml', { user_id: user._id }) ) } 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) } 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 next(null, cache) } ) } ) }, setAddressAndCompanyBillingInfo(cache, next) { const { user } = cache const { subscriptionDetails } = cache logger.debug( { user_id: user._id }, 'setting billing address and company info in recurly' ) const accountCode = cache?.account?.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) } let requestBody try { requestBody = RecurlyWrapper._buildXml( 'billing_info', addressAndCompanyBillingInfo ) } catch (error) { return next( OError.tag(error, 'error building xml', { user_id: user._id }) ) } 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) } 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 next(null, cache) } ) } ) }, createSubscription(cache, next) { const { user } = cache const { subscriptionDetails } = cache logger.debug({ 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 } let requestBody try { requestBody = RecurlyWrapper._buildXml('subscription', data) } catch (error) { return next( OError.tag(error, 'error building xml', { user_id: user._id }) ) } 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) } RecurlyWrapper._parseSubscriptionXml( responseBody, function (err, subscription) { if (err) { OError.tag(err, 'error creating subscription', { user_id: user._id, }) return next(err) } cache.subscription = subscription next(null, cache) } ) } ) }, }, _createPaypalSubscription( user, subscriptionDetails, recurlyTokenIds, callback ) { logger.debug( { 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 } 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.debug( { user_id: user._id }, 'done creating paypal subscription for user' ) 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 } let requestBody try { requestBody = RecurlyWrapper._buildXml('subscription', data) } catch (error) { return callback( OError.tag(error, 'error building xml', { user_id: user._id }) ) } RecurlyWrapper.apiRequest( { url: 'subscriptions', method: 'POST', body: requestBody, expect422: true, }, (error, response, responseBody) => { if (error) { 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.debug( { 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 ${Buffer.from( 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 request(options, function (error, response, body) { if ( !error && 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 ? response.statusCode : undefined, }, 'error returned from recurly' ) error = new OError( `Recurly API returned with status code: ${response.statusCode}`, { statusCode: response.statusCode } ) } callback(error, response, body) }) }, getSubscriptions(accountId, callback) { RecurlyWrapper.apiRequest( { url: `accounts/${accountId}/subscriptions`, }, (error, response, body) => { if (error) { return callback(error) } RecurlyWrapper._parseXml(body, callback) } ) }, getSubscription(subscriptionId, options, callback) { let url if (!callback) { callback = options } if (!options) { options = {} } if (options.recurlyJsResult) { url = `recurly_js/result/${subscriptionId}` } else { url = `subscriptions/${subscriptionId}` } RecurlyWrapper.apiRequest( { url, }, (error, response, body) => { if (error) { return callback(error) } RecurlyWrapper._parseSubscriptionXml( body, (error, recurlySubscription) => { if (error) { return callback(error) } if (options.includeAccount) { let accountId if ( recurlySubscription.account && recurlySubscription.account.url ) { accountId = recurlySubscription.account.url.match(/accounts\/(.*)/)[1] } else { return callback( new Error("I don't understand the response from Recurly") ) } RecurlyWrapper.getAccount(accountId, function (error, account) { if (error) { return callback(error) } recurlySubscription.account = account callback(null, recurlySubscription) }) } else { callback(null, recurlySubscription) } } ) } ) }, getPaginatedEndpoint(resource, queryParams, callback) { queryParams.per_page = queryParams.per_page || 200 let allItems = [] const getPage = (cursor = null) => { const opts = { url: resource, qs: queryParams, } if (cursor) { opts.qs.cursor = cursor } return RecurlyWrapper.apiRequest(opts, (error, response, body) => { if (error) { return callback(error) } return RecurlyWrapper._parseXml(body, function (err, data) { if (err) { logger.warn({ err }, 'could not get accoutns') return callback(err) } const items = data[resource] allItems = allItems.concat(items) logger.debug( `got another ${items.length}, total now ${allItems.length}` ) const match = response.headers.link?.match( /cursor=([0-9.]+%3A[0-9.]+)&/ ) cursor = match && match[1] if (cursor) { cursor = decodeURIComponent(cursor) return getPage(cursor) } else { callback(err, allItems) } }) }) } getPage() }, getAccount(accountId, callback) { RecurlyWrapper.apiRequest( { url: `accounts/${accountId}`, }, (error, response, body) => { if (error) { return callback(error) } RecurlyWrapper._parseAccountXml(body, callback) } ) }, updateAccountEmailAddress, getAccountActiveCoupons(accountId, callback) { RecurlyWrapper.apiRequest( { url: `accounts/${accountId}/redemptions`, }, (error, response, body) => { if (error) { return callback(error) } RecurlyWrapper._parseRedemptionsXml( body, function (error, redemptions) { if (error) { return callback(error) } const activeRedemptions = redemptions.filter( redemption => redemption.state === 'active' ) const couponCodes = activeRedemptions.map( redemption => redemption.coupon_code ) Async.map( couponCodes, RecurlyWrapper.getCoupon, function (error, coupons) { if (error) { return callback(error) } return callback(null, coupons) } ) } ) } ) }, getCoupon(couponCode, callback) { const opts = { url: `coupons/${couponCode}` } RecurlyWrapper.apiRequest(opts, (error, response, body) => { if (error) { return callback(error) } RecurlyWrapper._parseCouponXml(body, callback) }) }, getBillingInfo(accountId, callback) { RecurlyWrapper.apiRequest( { url: `accounts/${accountId}/billing_info`, }, (error, response, body) => { if (error) { return callback(error) } 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.debug( { subscriptionId, options }, 'telling recurly to update subscription' ) const data = { plan_code: options.plan_code, timeframe: options.timeframe, } let requestBody try { requestBody = RecurlyWrapper._buildXml('subscription', data) } catch (error) { return callback( OError.tag(error, 'error building xml', { subscriptionId }) ) } RecurlyWrapper.apiRequest( { url: `subscriptions/${subscriptionId}`, method: 'put', body: requestBody, }, (error, response, responseBody) => { if (error) { return callback(error) } RecurlyWrapper._parseSubscriptionXml(responseBody, callback) } ) }, createFixedAmmountCoupon( couponCode, name, currencyCode, discountInCents, planCode, callback ) { const data = { coupon_code: couponCode, name, discount_type: 'dollars', discount_in_cents: {}, plan_codes: { plan_code: planCode, }, applies_to_all_plans: false, } data.discount_in_cents[currencyCode] = discountInCents let requestBody try { requestBody = RecurlyWrapper._buildXml('coupon', data) } catch (error) { return callback( OError.tag(error, 'error building xml', { couponCode, name, }) ) } logger.debug({ couponCode, requestBody }, 'creating coupon') RecurlyWrapper.apiRequest( { url: 'coupons', method: 'post', body: requestBody, }, (error, response, responseBody) => { if (error) { logger.warn({ err: error, couponCode }, 'error creating coupon') } callback(error) } ) }, lookupCoupon(couponCode, callback) { RecurlyWrapper.apiRequest( { url: `coupons/${couponCode}`, }, (error, response, body) => { if (error) { return callback(error) } RecurlyWrapper._parseXml(body, callback) } ) }, redeemCoupon(accountCode, couponCode, callback) { const data = { account_code: accountCode, currency: 'USD', } let requestBody try { requestBody = RecurlyWrapper._buildXml('redemption', data) } catch (error) { return callback( OError.tag(error, 'error building xml', { accountCode, couponCode, }) ) } logger.debug( { accountCode, couponCode, requestBody }, 'redeeming coupon for user' ) RecurlyWrapper.apiRequest( { url: `coupons/${couponCode}/redeem`, method: 'post', body: requestBody, }, (error, response, responseBody) => { if (error) { logger.warn( { err: error, accountCode, couponCode }, 'error redeeming coupon' ) } callback(error) } ) }, extendTrial(subscriptionId, daysUntilExpire, callback) { if (daysUntilExpire == null) { daysUntilExpire = 7 } const nextRenewalDate = new Date() nextRenewalDate.setDate(nextRenewalDate.getDate() + daysUntilExpire) logger.debug( { subscriptionId, daysUntilExpire }, 'Exending Free trial for user' ) RecurlyWrapper.apiRequest( { url: `/subscriptions/${subscriptionId}/postpone?next_renewal_date=${nextRenewalDate}&bulk=false`, method: 'put', }, (error, response, responseBody) => { if (error) { logger.warn( { err: error, subscriptionId, daysUntilExpire }, 'error exending trial' ) } callback(error) } ) }, listAccountActiveSubscriptions(accountId, callback) { RecurlyWrapper.apiRequest( { url: `accounts/${accountId}/subscriptions`, qs: { state: 'active', }, expect404: true, }, function (error, response, body) { if (error) { return callback(error) } if (response.statusCode === 404) { callback(null, []) } else { 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) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'subscriptions', callback) }, _parseSubscriptionXml(xml, callback) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'subscription', callback) }, _parseAccountXml(xml, callback) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'account', callback) }, _parseBillingInfoXml(xml, callback) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'billing_info', callback) }, _parseRedemptionsXml(xml, callback) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'redemptions', callback) }, _parseCouponXml(xml, callback) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'coupon', callback) }, _parseErrorsXml(xml, callback) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'errors', callback) }, _parseInvoicesXml(xml, callback) { RecurlyWrapper._parseXmlAndGetAttribute(xml, 'invoices', callback) }, _parseXmlAndGetAttribute(xml, attribute, callback) { RecurlyWrapper._parseXml(xml, function (error, data) { if (error) { return callback(error) } if (data && data[attribute] != null) { callback(null, data[attribute]) } else { callback(new Error("I don't understand the response from Recurly")) } }) }, _parseXml(xml, callback) { function convertDataTypes(data) { let key, value if (data && data.$) { if (data.$.nil === 'nil') { data = null } else if (data.$.href) { 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 = 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: '', }) parser.parseString(xml, function (error, data) { if (error) { return callback(error) } const result = convertDataTypes(data) 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 = { getSubscription: promisify(RecurlyWrapper.getSubscription), 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 }