overleaf/services/web/app/src/Features/Subscription/RecurlyWrapper.js
Thomas 2d8e832be7 Subscription code decaf cleanup (#7918)
GitOrigin-RevId: 43adff9af7ca347808980823ac641db05129a79b
2022-05-18 08:04:31 +00:00

1112 lines
29 KiB
JavaScript

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
}