Files
overleaf-cep/services/web/app/src/Features/Subscription/RecurlyWrapper.js
T
Liangjun Song f2f238be99 Merge pull request #23060 from overleaf/ls-support-new-group-plan-in-redesigned-checkout-page
Support new group plan in the redesigned checkout page

GitOrigin-RevId: 2564477b36ee93ca4b4b6dd8210f140a3104c6ed
2025-01-29 09:05:09 +00:00

991 lines
27 KiB
JavaScript

const OError = require('@overleaf/o-error')
const {
fetchStringWithResponse,
RequestFailedError,
} = require('@overleaf/fetch-utils')
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')
/**
* @param accountId
* @param newEmail
*/
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.status === 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,
},
}
if (subscriptionDetails.subscription_add_ons) {
data.subscription_add_ons = subscriptionDetails.subscription_add_ons
}
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
}
if (subscriptionDetails.subscription_add_ons) {
data.subscription_add_ons = subscriptionDetails.subscription_add_ons
}
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.status === 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}>}
*/
async apiRequest({ expect404, expect422, url, qs, ...fetchOptions }) {
const fetchUrl = new URL(RecurlyWrapper.apiUrl)
fetchUrl.pathname =
fetchUrl.pathname !== '/' ? `${fetchUrl.pathname}/${url}` : url
if (qs) {
for (const [key, value] of Object.entries(qs)) {
fetchUrl.searchParams.set(key, value)
}
}
fetchOptions.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,
}
try {
return await fetchStringWithResponse(fetchUrl, fetchOptions)
} catch (error) {
if (error instanceof RequestFailedError) {
if (error.response.status === 404 && expect404) {
return { response: error.response, body: null }
} else if (error.response.status === 422 && expect422) {
return { response: error.response, body: error.body }
}
if (fetchOptions.headers.Authorization) {
fetchOptions.headers.Authorization = 'REDACTED'
}
logger.warn(
{
err: error,
body: error.body,
options: fetchOptions,
url: fetchUrl.href,
statusCode: error.response?.status,
},
'error returned from recurly'
)
throw new OError(
`Recurly API returned with status code: ${error.response.status}`,
{ statusCode: error.response.status }
)
} else {
throw error
}
}
},
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
}
},
/**
* @typedef {{getNextPage: () => Promise<PageData>, items: any[]}} PageData
*/
async getPaginatedEndpoint(resource, queryParams) {
let allItems = []
let items
/** @type {() => Promise<PageData>} */
let getNextPage = promises.getPaginatedEndpointIterator(
resource,
queryParams
)
while (getNextPage) {
;({ items, getNextPage } = await getNextPage())
allItems = allItems.concat(items)
logger.debug(`total now ${allItems.length}`)
}
return allItems
},
/**
* @returns {() => Promise<PageData>}
*/
getPaginatedEndpointIterator(resource, queryParams) {
queryParams.per_page = queryParams.per_page || 200
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]
logger.debug(`got ${items.length} items in this page`)
const match = response.headers.link?.match(/cursor=([0-9.]+%3A[0-9.]+)&/)
const nextCursor = match && match[1]
return {
items,
getNextPage:
nextCursor && (() => getPage(decodeURIComponent(nextCursor))),
}
}
return getPage
},
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`,
qs: { 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 createFixedAmountCoupon(
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`,
qs: { bulk: false, next_bill_date: nextRenewalDate },
method: 'PUT',
})
} catch (error) {
logger.warn(
{ err: error, subscriptionId, daysUntilExpire },
'error extending trial'
)
throw error
}
},
async listAccountActiveSubscriptions(accountId) {
const { response, body } = await RecurlyWrapper.promises.apiRequest({
url: `accounts/${accountId}/subscriptions`,
qs: {
state: 'active',
},
expect404: true,
})
if (response.status === 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")
}
},
/**
* @param xml
*/
_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),
createFixedAmountCoupon: callbackify(promises.createFixedAmountCoupon),
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
}