Files
overleaf-cep/services/web/app/src/Features/Subscription/RecurlyWrapper.js
T
Eric Mc Sween a22319fe46 Merge pull request #18453 from overleaf/em-fix-floating-promise
Fix no-floating-promises lint rule

GitOrigin-RevId: 58f89e0078abf72be65552f9468cb225e5758e3e
2024-05-27 10:22:24 +00:00

955 lines
27 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 Errors = require('../Errors/Errors')
const SubscriptionErrors = require('./Errors')
const { callbackify } = require('@overleaf/promise-utils')
async function updateAccountEmailAddress(accountId, newEmail) {
const data = {
email: newEmail,
}
let requestBody
try {
requestBody = RecurlyWrapper._buildXml('account', data)
} catch (error) {
throw OError.tag(error, 'error building xml', { accountId, newEmail })
}
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}`,
method: 'PUT',
body: requestBody,
})
return await RecurlyWrapper.promises._parseAccountXml(body)
}
const promises = {
_paypal: {
async checkAccountExists(cache) {
const { user } = cache
logger.debug(
{ userId: user._id },
'checking if recurly account exists for user'
)
let response, body
try {
;({ response, body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${user._id}`,
method: 'GET',
expect404: true,
}))
} catch (error) {
OError.tag(
error,
'error response from recurly while checking account',
{
user_id: user._id,
}
)
throw error
}
if (response.statusCode === 404) {
// actually not an error in this case, just no existing account
logger.debug(
{ userId: user._id },
'user does not currently exist in recurly, proceed'
)
cache.userExists = false
return cache
}
logger.debug({ userId: user._id }, 'user appears to exist in recurly')
try {
const account = await RecurlyWrapper.promises._parseAccountXml(body)
cache.userExists = true
cache.account = account
return cache
} catch (err) {
OError.tag(err, 'error parsing account', {
user_id: user._id,
})
throw err
}
},
async createAccount(cache) {
const { user } = cache
const { subscriptionDetails } = cache
if (cache.userExists) {
return cache
}
const address = getAddressFromSubscriptionDetails(
subscriptionDetails,
false
)
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) {
throw OError.tag(error, 'error building xml', { user_id: user._id })
}
let body
try {
;({ body } = await RecurlyWrapper.promises.apiRequest({
url: 'accounts',
method: 'POST',
body: requestBody,
}))
} catch (error) {
OError.tag(
error,
'error response from recurly while creating account',
{ user_id: user._id }
)
throw error
}
try {
cache.account = await RecurlyWrapper.promises._parseAccountXml(body)
return cache
} catch (err) {
OError.tag(err, 'error creating account', {
user_id: user._id,
})
throw err
}
},
async createBillingInfo(cache) {
const { user } = cache
const { recurlyTokenIds } = cache
logger.debug({ userId: user._id }, 'creating billing info in recurly')
const accountCode = cache?.account?.account_code
if (!accountCode) {
throw 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) {
throw OError.tag(error, 'error building xml', { user_id: user._id })
}
let body
try {
;({ body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountCode}/billing_info`,
method: 'POST',
body: requestBody,
}))
} catch (error) {
OError.tag(
error,
'error response from recurly while creating billing info',
{ user_id: user._id }
)
throw error
}
try {
cache.billingInfo =
await RecurlyWrapper.promises._parseBillingInfoXml(body)
return cache
} catch (err) {
OError.tag(err, 'error creating billing info', {
user_id: user._id,
accountCode,
})
throw err
}
},
async setAddressAndCompanyBillingInfo(cache) {
const { user } = cache
const { subscriptionDetails } = cache
logger.debug(
{ userId: user._id },
'setting billing address and company info in recurly'
)
const accountCode = cache?.account?.account_code
if (!accountCode) {
throw new Error(
'no account code at setAddressAndCompanyBillingInfo stage'
)
}
const addressAndCompanyBillingInfo = getAddressFromSubscriptionDetails(
subscriptionDetails,
true
)
let requestBody
try {
requestBody = RecurlyWrapper._buildXml(
'billing_info',
addressAndCompanyBillingInfo
)
} catch (error) {
throw OError.tag(error, 'error building xml', { user_id: user._id })
}
let body
try {
;({ body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountCode}/billing_info`,
method: 'PUT',
body: requestBody,
}))
} catch (error) {
OError.tag(error, 'error response from recurly while setting address', {
user_id: user._id,
})
throw error
}
try {
cache.billingInfo =
await RecurlyWrapper.promises._parseBillingInfoXml(body)
return cache
} catch (err) {
if (err) {
OError.tag(err, 'error updating billing info', {
user_id: user._id,
})
throw err
}
}
},
async createSubscription(cache) {
const { user } = cache
const { subscriptionDetails } = cache
logger.debug({ userId: 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) {
throw OError.tag(error, 'error building xml', { user_id: user._id })
}
let body
try {
;({ body } = await RecurlyWrapper.promises.apiRequest({
url: 'subscriptions',
method: 'POST',
body: requestBody,
}))
} catch (error) {
OError.tag(
error,
'error response from recurly while creating subscription',
{ user_id: user._id }
)
throw error
}
try {
cache.subscription =
await RecurlyWrapper.promises._parseSubscriptionXml(body)
return cache
} catch (err) {
OError.tag(err, 'error creating subscription', {
user_id: user._id,
})
throw err
}
},
},
async _createPaypalSubscription(user, subscriptionDetails, recurlyTokenIds) {
logger.debug(
{ userId: user._id },
'starting process of creating paypal subscription'
)
// We use waterfall through 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 }
let result
try {
result = await RecurlyWrapper.promises._paypal.checkAccountExists(cache)
result = await RecurlyWrapper.promises._paypal.createAccount(result)
result = await RecurlyWrapper.promises._paypal.createBillingInfo(result)
result =
await RecurlyWrapper.promises._paypal.setAddressAndCompanyBillingInfo(
result
)
result = await RecurlyWrapper.promises._paypal.createSubscription(result)
} catch (err) {
OError.tag(err, 'error in paypal subscription creation process', {
user_id: user._id,
})
throw err
}
if (!result.subscription) {
const err = new Error('no subscription object in result')
OError.tag(err, 'error in paypal subscription creation process', {
user_id: user._id,
})
throw err
}
logger.debug(
{ userId: user._id },
'done creating paypal subscription for user'
)
return result.subscription
},
async _createCreditCardSubscription(
user,
subscriptionDetails,
recurlyTokenIds
) {
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) {
throw OError.tag(error, 'error building xml', { user_id: user._id })
}
const { response, body } = await RecurlyWrapper.promises.apiRequest({
url: 'subscriptions',
method: 'POST',
body: requestBody,
expect422: true,
})
if (response.statusCode === 422) {
return await RecurlyWrapper.promises._handle422Response(body)
} else {
return await RecurlyWrapper.promises._parseSubscriptionXml(body)
}
},
async createSubscription(user, subscriptionDetails, recurlyTokenIds) {
const { isPaypal } = subscriptionDetails
logger.debug(
{ userId: user._id, isPaypal },
'setting up subscription in recurly'
)
const fn = isPaypal
? RecurlyWrapper.promises._createPaypalSubscription
: RecurlyWrapper.promises._createCreditCardSubscription
return fn(user, subscriptionDetails, recurlyTokenIds)
},
/**
* @param options - the options to pass to the request library
* @returns {Promise<{ response: unknown, body: string}>}
*/
apiRequest(options) {
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
return new Promise((resolve, reject) => {
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)
) {
if (options.headers.Authorization) {
options.headers.Authorization = 'REDACTED'
}
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 }
)
reject(error)
}
resolve({ response, body })
})
})
},
async getSubscriptions(accountId) {
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}/subscriptions`,
})
return await RecurlyWrapper.promises._parseXml(body)
},
async getSubscription(subscriptionId, options) {
let url
if (!options) {
options = {}
}
if (options.recurlyJsResult) {
url = `recurly_js/result/${subscriptionId}`
} else {
url = `subscriptions/${subscriptionId}`
}
const { body } = await RecurlyWrapper.promises.apiRequest({
url,
})
const recurlySubscription =
await RecurlyWrapper.promises._parseSubscriptionXml(body)
if (options.includeAccount) {
let accountId
if (recurlySubscription.account && recurlySubscription.account.url) {
accountId = recurlySubscription.account.url.match(/accounts\/(.*)/)[1]
} else {
throw new Error("I don't understand the response from Recurly")
}
recurlySubscription.account =
await RecurlyWrapper.promises.getAccount(accountId)
return recurlySubscription
} else {
return recurlySubscription
}
},
async getPaginatedEndpoint(resource, queryParams) {
queryParams.per_page = queryParams.per_page || 200
let allItems = []
const getPage = async (cursor = null) => {
const opts = {
url: resource,
qs: queryParams,
}
if (cursor) {
opts.qs.cursor = cursor
}
const { response, body } = await RecurlyWrapper.promises.apiRequest(opts)
const data = await RecurlyWrapper.promises._parseXml(body)
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 {
return allItems
}
}
await getPage()
return allItems
},
async getAccount(accountId) {
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}`,
})
return await RecurlyWrapper.promises._parseAccountXml(body)
},
updateAccountEmailAddress,
async getAccountActiveCoupons(accountId) {
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}/redemptions`,
})
const redemptions = await RecurlyWrapper.promises._parseRedemptionsXml(body)
const activeRedemptions = redemptions.filter(
redemption => redemption.state === 'active'
)
const couponCodes = activeRedemptions.map(
redemption => redemption.coupon_code
)
return await Promise.all(
couponCodes.map(couponCode =>
RecurlyWrapper.promises.getCoupon(couponCode)
)
)
},
async getCoupon(couponCode) {
const opts = { url: `coupons/${couponCode}` }
const { body } = await RecurlyWrapper.promises.apiRequest(opts)
return await RecurlyWrapper.promises._parseCouponXml(body)
},
async getBillingInfo(accountId) {
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}/billing_info`,
})
return await RecurlyWrapper.promises._parseXml(body)
},
async getAccountPastDueInvoices(accountId) {
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}/invoices?state=past_due`,
})
return await RecurlyWrapper.promises._parseInvoicesXml(body)
},
async attemptInvoiceCollection(invoiceId) {
return await RecurlyWrapper.promises.apiRequest({
url: `invoices/${invoiceId}/collect`,
method: 'put',
})
},
async updateSubscription(subscriptionId, options) {
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) {
throw OError.tag(error, 'error building xml', { subscriptionId })
}
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `subscriptions/${subscriptionId}`,
method: 'put',
body: requestBody,
})
return await RecurlyWrapper.promises._parseSubscriptionXml(body)
},
async createFixedAmmountCoupon(
couponCode,
name,
currencyCode,
discountInCents,
planCode
) {
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) {
throw OError.tag(error, 'error building xml', {
couponCode,
name,
})
}
logger.debug({ couponCode, requestBody }, 'creating coupon')
try {
await RecurlyWrapper.promises.apiRequest({
url: 'coupons',
method: 'post',
body: requestBody,
})
} catch (error) {
logger.warn({ err: error, couponCode }, 'error creating coupon')
throw error
}
},
async lookupCoupon(couponCode) {
const { body } = await RecurlyWrapper.promises.apiRequest({
url: `coupons/${couponCode}`,
})
return await RecurlyWrapper.promises._parseCouponXml(body)
},
async redeemCoupon(accountCode, couponCode) {
const data = {
account_code: accountCode,
currency: 'USD',
}
let requestBody
try {
requestBody = RecurlyWrapper._buildXml('redemption', data)
} catch (error) {
throw OError.tag(error, 'error building xml', {
accountCode,
couponCode,
})
}
logger.debug(
{ accountCode, couponCode, requestBody },
'redeeming coupon for user'
)
try {
await RecurlyWrapper.promises.apiRequest({
url: `coupons/${couponCode}/redeem`,
method: 'post',
body: requestBody,
})
} catch (error) {
logger.warn(
{ err: error, accountCode, couponCode },
'error redeeming coupon'
)
throw error
}
},
async extendTrial(subscriptionId, daysUntilExpire) {
if (daysUntilExpire == null) {
daysUntilExpire = 7
}
const nextRenewalDate = new Date()
nextRenewalDate.setDate(nextRenewalDate.getDate() + daysUntilExpire)
logger.debug(
{ subscriptionId, daysUntilExpire },
'Exending Free trial for user'
)
try {
await RecurlyWrapper.promises.apiRequest({
url: `/subscriptions/${subscriptionId}/postpone?next_bill_date=${nextRenewalDate}&bulk=false`,
method: 'put',
})
} catch (error) {
logger.warn(
{ err: error, subscriptionId, daysUntilExpire },
'error exending trial'
)
throw error
}
},
async listAccountActiveSubscriptions(accountId) {
const { response, body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}/subscriptions`,
qs: {
state: 'active',
},
expect404: true,
})
if (response.statusCode === 404) {
return []
} else {
return await RecurlyWrapper.promises._parseSubscriptionsXml(body)
}
},
async _handle422Response(body) {
const data = await RecurlyWrapper.promises._parseErrorsXml(body)
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._,
},
},
}
}
throw new SubscriptionErrors.RecurlyTransactionError(errorData)
},
async _parseSubscriptionsXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
xml,
'subscriptions'
)
},
async _parseSubscriptionXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
xml,
'subscription'
)
},
async _parseAccountXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
xml,
'account'
)
},
async _parseBillingInfoXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
xml,
'billing_info'
)
},
async _parseRedemptionsXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
xml,
'redemptions'
)
},
async _parseCouponXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(xml, 'coupon')
},
async _parseErrorsXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(xml, 'errors')
},
async _parseInvoicesXml(xml) {
return await RecurlyWrapper.promises._parseXmlAndGetAttribute(
xml,
'invoices'
)
},
async _parseXmlAndGetAttribute(xml, attribute) {
const data = await RecurlyWrapper.promises._parseXml(xml)
if (data && data[attribute] != null) {
return data[attribute]
} else {
throw new Error("I don't understand the response from Recurly")
}
},
_parseXml(xml) {
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: '',
})
return new Promise((resolve, reject) =>
parser.parseString(xml, function (error, data) {
if (error) {
return reject(error)
}
const result = convertDataTypes(data)
resolve(result)
})
)
},
}
function _buildXml(rootName, data) {
const options = {
headless: true,
renderOpts: {
pretty: true,
indent: '\t',
},
rootName,
}
const builder = new xml2js.Builder(options)
return builder.buildObject(data)
}
const RecurlyWrapper = {
apiUrl: Settings.apis.recurly.url || 'https://api.recurly.com/v2',
_buildXml,
_parseXml: callbackify(promises._parseXml),
// This one needs to be callbackified manually because we need to transform {response, body} to (err, response, body)
attemptInvoiceCollection: (invoiceId, callback) => {
promises
.attemptInvoiceCollection(invoiceId)
.then(({ response, body }) => callback(null, response, body))
.catch(err => callback(err))
},
createFixedAmmountCoupon: callbackify(promises.createFixedAmmountCoupon),
getAccountActiveCoupons: callbackify(promises.getAccountActiveCoupons),
getBillingInfo: callbackify(promises.getBillingInfo),
getPaginatedEndpoint: callbackify(promises.getPaginatedEndpoint),
getSubscription: callbackify(promises.getSubscription),
getSubscriptions: callbackify(promises.getSubscriptions),
updateAccountEmailAddress: callbackify(promises.updateAccountEmailAddress),
}
RecurlyWrapper.promises = {
...promises,
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,
})
}
if (subscriptionDetails.ITMReferrer) {
customFields.push({
name: 'itm_referrer',
value: subscriptionDetails.ITMReferrer,
})
}
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
}